From e145f1d817f7cba2a7f04fd55680897d9c466643 Mon Sep 17 00:00:00 2001 From: jbardet Date: Mon, 20 Jul 2026 09:09:38 +0200 Subject: [PATCH 1/3] feat: management REST API for projects, profiles, forward nodes, auth configs, and monitoring Adds a programmatic configuration surface alongside the Vaadin UI: ProjectController, ProfileController, ForwardNodeController, AuthConfigController, MonitoringController. Auth reuses the existing Spring Security setup (HTTP Basic added to the in-memory IdP chain alongside the existing form login; OIDC unchanged). Also fixes a pre-existing bug surfaced while adding project deletion via the API: ProjectService.remove() left dangling FKs on destinations that referenced the deleted project (de-identification or tag morphing), which would violate the database constraint on delete. Monitoring endpoints are adapted to the current aggregated per-series model (TransferSeriesStatusEntity) via the specification-based pagination/count TransferSeriesStatusRepo already provides. Ported from jbardet/karnak (1.1.1-based fork) onto current master. --- pom.xml | 1 + .../config/SecurityInMemoryConfig.java | 5 + .../org/karnak/backend/constant/EndPoint.java | 10 + .../controller/AuthConfigController.java | 129 ++++++ .../controller/ForwardNodeController.java | 258 +++++++++++ .../controller/MonitoringController.java | 153 +++++++ .../backend/controller/ProfileController.java | 184 ++++++++ .../backend/controller/ProjectController.java | 415 ++++++++++++++++++ .../backend/data/entity/AuthConfigEntity.java | 3 + .../data/entity/DestinationEntity.java | 3 +- .../data/entity/DicomSourceNodeEntity.java | 2 + .../backend/data/entity/ProjectEntity.java | 3 + .../backend/data/entity/SecretEntity.java | 3 + .../backend/service/ProjectService.java | 13 + .../service/TransferMonitoringService.java | 19 +- .../controller/AuthConfigControllerTest.java | 171 ++++++++ .../controller/ForwardNodeControllerTest.java | 210 +++++++++ .../controller/MonitoringControllerTest.java | 148 +++++++ .../controller/ProfileControllerTest.java | 137 ++++++ .../controller/ProjectControllerTest.java | 288 ++++++++++++ .../data/entity/ProjectEntityTest.java | 13 + .../backend/service/ProjectServiceTest.java | 32 ++ 22 files changed, 2198 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/karnak/backend/controller/AuthConfigController.java create mode 100644 src/main/java/org/karnak/backend/controller/ForwardNodeController.java create mode 100644 src/main/java/org/karnak/backend/controller/MonitoringController.java create mode 100644 src/main/java/org/karnak/backend/controller/ProfileController.java create mode 100644 src/main/java/org/karnak/backend/controller/ProjectController.java create mode 100644 src/test/java/org/karnak/backend/controller/AuthConfigControllerTest.java create mode 100644 src/test/java/org/karnak/backend/controller/ForwardNodeControllerTest.java create mode 100644 src/test/java/org/karnak/backend/controller/MonitoringControllerTest.java create mode 100644 src/test/java/org/karnak/backend/controller/ProfileControllerTest.java create mode 100644 src/test/java/org/karnak/backend/controller/ProjectControllerTest.java diff --git a/pom.xml b/pom.xml index 643b609c..55bee832 100644 --- a/pom.xml +++ b/pom.xml @@ -191,6 +191,7 @@ maven-compiler-plugin ${java.version} + true org.projectlombok diff --git a/src/main/java/org/karnak/backend/config/SecurityInMemoryConfig.java b/src/main/java/org/karnak/backend/config/SecurityInMemoryConfig.java index 51873a8a..04b14cae 100644 --- a/src/main/java/org/karnak/backend/config/SecurityInMemoryConfig.java +++ b/src/main/java/org/karnak/backend/config/SecurityInMemoryConfig.java @@ -22,6 +22,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -62,6 +63,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // Allow endpoints .requestMatchers(HttpMethod.GET, "/api/echo/destinations") .permitAll()) + // Enable HTTP Basic authentication so the management REST API (/api/**, + // beyond the explicitly permitted echo endpoint) can be called by + // non-browser clients, alongside the Vaadin/form-login session below. + .httpBasic(Customizer.withDefaults()) // Vaadin/Spring Security integration: permits the framework internal // requests and the @AnonymousAllowed views, scopes CSRF, configures the // request cache, the form login on the login view and requires diff --git a/src/main/java/org/karnak/backend/constant/EndPoint.java b/src/main/java/org/karnak/backend/constant/EndPoint.java index 476b7614..33056144 100644 --- a/src/main/java/org/karnak/backend/constant/EndPoint.java +++ b/src/main/java/org/karnak/backend/constant/EndPoint.java @@ -16,6 +16,16 @@ public class EndPoint { public static final String DESTINATIONS_PATH = "/destinations"; + public static final String FORWARD_NODES_PATH = "/api/forward-nodes"; + + public static final String PROFILES_PATH = "/api/profiles"; + + public static final String PROJECTS_PATH = "/api/projects"; + + public static final String AUTH_CONFIGS_PATH = "/api/auth-configs"; + + public static final String MONITORING_PATH = "/api/monitoring"; + // Params public static final String SRC_AET_PARAM = "srcAet"; diff --git a/src/main/java/org/karnak/backend/controller/AuthConfigController.java b/src/main/java/org/karnak/backend/controller/AuthConfigController.java new file mode 100644 index 00000000..9d237611 --- /dev/null +++ b/src/main/java/org/karnak/backend/controller/AuthConfigController.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2024-2026 Karnak Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.karnak.backend.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import org.karnak.backend.constant.EndPoint; +import org.karnak.backend.data.entity.AuthConfigEntity; +import org.karnak.backend.data.repo.AuthConfigRepo; +import org.karnak.backend.enums.AuthConfigType; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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; + +/** + * Rest controller managing OAuth2 authentication configurations for STOW-RS destinations + */ +@RestController +@RequestMapping(EndPoint.AUTH_CONFIGS_PATH) +@Tag(name = "AuthConfig", description = "API Endpoints for Authentication Configurations (OAuth2)") +public class AuthConfigController { + + private final AuthConfigRepo authConfigRepo; + + @Autowired + public AuthConfigController(final AuthConfigRepo authConfigRepo) { + this.authConfigRepo = authConfigRepo; + } + + @Operation(summary = "List all authentication configurations") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Auth configs found"), + @ApiResponse(responseCode = "204", description = "No auth configs", content = @Content) }) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getAllAuthConfigs() { + List configs = authConfigRepo.findAll(); + return configs.isEmpty() ? ResponseEntity.noContent().build() : ResponseEntity.ok(configs); + } + + @Operation(summary = "Create an authentication configuration", + description = "Body: {\"code\": \"my-auth\", \"clientId\": \"...\", \"clientSecret\": \"...\"," + + " \"accessTokenUrl\": \"https://...\", \"scope\": \"openid\"}") + @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "Auth config created"), + @ApiResponse(responseCode = "400", description = "Missing required fields", content = @Content), + @ApiResponse(responseCode = "409", description = "Code already exists", content = @Content) }) + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createAuthConfig(@RequestBody AuthConfigEntity authConfigEntity) { + if (authConfigEntity.getCode() == null || authConfigEntity.getCode().isBlank()) { + return ResponseEntity.badRequest().build(); + } + if (authConfigRepo.findByCode(authConfigEntity.getCode()) != null) { + return ResponseEntity.status(409).build(); + } + authConfigEntity.setId(null); + if (authConfigEntity.getAuthConfigType() == null) { + authConfigEntity.setAuthConfigType(AuthConfigType.OAUTH2); + } + AuthConfigEntity saved = authConfigRepo.save(authConfigEntity); + return ResponseEntity.status(201).body(saved); + } + + @Operation(summary = "Get an authentication configuration by its code identifier") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Auth config found"), + @ApiResponse(responseCode = "404", description = "Auth config not found", content = @Content) }) + @GetMapping(value = "/{code}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getAuthConfig(@PathVariable String code) { + AuthConfigEntity config = authConfigRepo.findByCode(code); + return config == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(config); + } + + @Operation(summary = "Update an authentication configuration", + description = "Updatable fields: clientId, clientSecret, accessTokenUrl, scope") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Auth config updated"), + @ApiResponse(responseCode = "404", description = "Auth config not found", content = @Content) }) + @PutMapping(value = "/{code}", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity updateAuthConfig(@PathVariable String code, + @RequestBody AuthConfigEntity incoming) { + AuthConfigEntity existing = authConfigRepo.findByCode(code); + if (existing == null) { + return ResponseEntity.notFound().build(); + } + if (incoming.getClientId() != null) { + existing.setClientId(incoming.getClientId()); + } + if (incoming.getClientSecret() != null) { + existing.setClientSecret(incoming.getClientSecret()); + } + if (incoming.getAccessTokenUrl() != null) { + existing.setAccessTokenUrl(incoming.getAccessTokenUrl()); + } + if (incoming.getScope() != null) { + existing.setScope(incoming.getScope()); + } + return ResponseEntity.ok(authConfigRepo.save(existing)); + } + + @Operation(summary = "Delete an authentication configuration") + @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "Auth config deleted"), + @ApiResponse(responseCode = "404", description = "Auth config not found", content = @Content) }) + @DeleteMapping(value = "/{code}") + public ResponseEntity deleteAuthConfig(@PathVariable String code) { + AuthConfigEntity existing = authConfigRepo.findByCode(code); + if (existing == null) { + return ResponseEntity.notFound().build(); + } + authConfigRepo.delete(existing); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/main/java/org/karnak/backend/controller/ForwardNodeController.java b/src/main/java/org/karnak/backend/controller/ForwardNodeController.java new file mode 100644 index 00000000..b806251b --- /dev/null +++ b/src/main/java/org/karnak/backend/controller/ForwardNodeController.java @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2024-2026 Karnak Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.karnak.backend.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import org.karnak.backend.constant.EndPoint; +import org.karnak.backend.data.entity.DestinationEntity; +import org.karnak.backend.data.entity.DicomSourceNodeEntity; +import org.karnak.backend.data.entity.ForwardNodeEntity; +import org.karnak.backend.service.DestinationService; +import org.karnak.backend.service.ForwardNodeAPIService; +import org.karnak.backend.service.ForwardNodeService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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; + +/** + * Rest controller managing forward nodes, their source nodes, and destinations + */ +@RestController +@RequestMapping(EndPoint.FORWARD_NODES_PATH) +@Tag(name = "ForwardNode", description = "API Endpoints for Forward Nodes") +public class ForwardNodeController { + + private final ForwardNodeAPIService forwardNodeAPIService; + + private final ForwardNodeService forwardNodeService; + + private final DestinationService destinationService; + + @Autowired + public ForwardNodeController(final ForwardNodeAPIService forwardNodeAPIService, + final ForwardNodeService forwardNodeService, final DestinationService destinationService) { + this.forwardNodeAPIService = forwardNodeAPIService; + this.forwardNodeService = forwardNodeService; + this.destinationService = destinationService; + } + + // ======== Forward Nodes ======== + + @Operation(summary = "List all forward nodes") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Forward nodes found"), + @ApiResponse(responseCode = "204", description = "No forward nodes configured", content = @Content) }) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getAllForwardNodes() { + List nodes = forwardNodeService.getAllForwardNodes(); + return nodes.isEmpty() ? ResponseEntity.noContent().build() : ResponseEntity.ok(nodes); + } + + @Operation(summary = "Create a forward node") + @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "Forward node created"), + @ApiResponse(responseCode = "400", description = "Missing or invalid fwdAeTitle", content = @Content), + @ApiResponse(responseCode = "409", description = "AE Title already exists", content = @Content) }) + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createForwardNode(@RequestBody ForwardNodeEntity forwardNodeEntity) { + String aeTitle = forwardNodeEntity.getFwdAeTitle(); + if (aeTitle == null || aeTitle.isBlank()) { + return ResponseEntity.badRequest().build(); + } + boolean aeExists = forwardNodeService.getAllForwardNodes() + .stream() + .anyMatch(f -> Objects.equals(f.getFwdAeTitle(), aeTitle)); + if (aeExists) { + return ResponseEntity.status(409).build(); + } + forwardNodeEntity.setId(null); + forwardNodeAPIService.addForwardNode(forwardNodeEntity); + return ResponseEntity.status(201).body(forwardNodeEntity); + } + + @Operation(summary = "Get a forward node by ID") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Forward node found"), + @ApiResponse(responseCode = "404", description = "Forward node not found", content = @Content) }) + @GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getForwardNode(@PathVariable Long id) { + ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id); + return node == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(node); + } + + @Operation(summary = "Update a forward node", + description = "Only fwdAeTitle and fwdDescription are updatable. " + + "Source nodes and destinations are preserved (manage them via their own endpoints).") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Forward node updated"), + @ApiResponse(responseCode = "404", description = "Forward node not found", content = @Content) }) + @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity updateForwardNode(@PathVariable Long id, + @RequestBody ForwardNodeEntity forwardNodeEntity) { + ForwardNodeEntity existing = forwardNodeAPIService.getForwardNodeById(id); + if (existing == null) { + return ResponseEntity.notFound().build(); + } + // Merge only fwdAeTitle/fwdDescription onto existing entity so that the + // associated source nodes and destinations (cascade=ALL + orphanRemoval) + // are not silently wiped out by an incoming partial payload. + if (forwardNodeEntity.getFwdAeTitle() != null && !forwardNodeEntity.getFwdAeTitle().isBlank()) { + existing.setFwdAeTitle(forwardNodeEntity.getFwdAeTitle()); + } + if (forwardNodeEntity.getFwdDescription() != null) { + existing.setFwdDescription(forwardNodeEntity.getFwdDescription()); + } + forwardNodeAPIService.updateForwardNode(existing); + return ResponseEntity.ok(existing); + } + + @Operation(summary = "Delete a forward node") + @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "Forward node deleted"), + @ApiResponse(responseCode = "404", description = "Forward node not found", content = @Content) }) + @DeleteMapping(value = "/{id}") + public ResponseEntity deleteForwardNode(@PathVariable Long id) { + ForwardNodeEntity existing = forwardNodeAPIService.getForwardNodeById(id); + if (existing == null) { + return ResponseEntity.notFound().build(); + } + forwardNodeAPIService.deleteForwardNode(existing); + return ResponseEntity.noContent().build(); + } + + // ======== Source Nodes ======== + + @Operation(summary = "List source nodes of a forward node") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Source nodes found"), + @ApiResponse(responseCode = "404", description = "Forward node not found", content = @Content) }) + @GetMapping(value = "/{id}/source-nodes", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getSourceNodes(@PathVariable Long id) { + ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id); + if (node == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(forwardNodeService.getAllSourceNodes(node)); + } + + @Operation(summary = "Add a source node to a forward node") + @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "Source node added"), + @ApiResponse(responseCode = "404", description = "Forward node not found", content = @Content) }) + @PostMapping(value = "/{id}/source-nodes", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity addSourceNode(@PathVariable Long id, + @RequestBody DicomSourceNodeEntity sourceNode) { + ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id); + if (node == null) { + return ResponseEntity.notFound().build(); + } + sourceNode.setId(null); + forwardNodeService.updateSourceNode(node, sourceNode); + return ResponseEntity.status(201).body(sourceNode); + } + + @Operation(summary = "Remove a source node from a forward node") + @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "Source node removed"), + @ApiResponse(responseCode = "404", description = "Forward node or source node not found", + content = @Content) }) + @DeleteMapping(value = "/{id}/source-nodes/{sourceNodeId}") + public ResponseEntity deleteSourceNode(@PathVariable Long id, @PathVariable Long sourceNodeId) { + ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id); + if (node == null) { + return ResponseEntity.notFound().build(); + } + DicomSourceNodeEntity sourceNode = forwardNodeService.getSourceNodeById(node, sourceNodeId); + if (sourceNode == null) { + return ResponseEntity.notFound().build(); + } + forwardNodeService.deleteSourceNode(node, sourceNode); + return ResponseEntity.noContent().build(); + } + + // ======== Destinations ======== + + @Operation(summary = "List destinations of a forward node") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Destinations found"), + @ApiResponse(responseCode = "404", description = "Forward node not found", content = @Content) }) + @GetMapping(value = "/{id}/destinations", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getDestinations(@PathVariable Long id) { + ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id); + if (node == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(destinationService.retrieveDestinations(node)); + } + + @Operation(summary = "Add a destination to a forward node") + @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "Destination added"), + @ApiResponse(responseCode = "404", description = "Forward node not found", content = @Content) }) + @PostMapping(value = "/{id}/destinations", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity addDestination(@PathVariable Long id, + @RequestBody DestinationEntity destinationEntity) { + ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id); + if (node == null) { + return ResponseEntity.notFound().build(); + } + destinationEntity.setId(null); + DestinationEntity saved = destinationService.save(node, destinationEntity); + return ResponseEntity.status(201).body(saved); + } + + @Operation(summary = "Update a destination of a forward node") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Destination updated"), + @ApiResponse(responseCode = "404", description = "Forward node or destination not found", + content = @Content) }) + @PutMapping(value = "/{id}/destinations/{destinationId}", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity updateDestination(@PathVariable Long id, @PathVariable Long destinationId, + @RequestBody DestinationEntity destinationEntity) { + ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id); + if (node == null) { + return ResponseEntity.notFound().build(); + } + DestinationEntity existing = forwardNodeService.getDestinationById(node, destinationId); + if (existing == null) { + return ResponseEntity.notFound().build(); + } + destinationEntity.setId(destinationId); + DestinationEntity saved = destinationService.save(node, destinationEntity); + return ResponseEntity.ok(saved); + } + + @Operation(summary = "Delete a destination from a forward node") + @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "Destination deleted"), + @ApiResponse(responseCode = "404", description = "Forward node or destination not found", + content = @Content) }) + @DeleteMapping(value = "/{id}/destinations/{destinationId}") + public ResponseEntity deleteDestination(@PathVariable Long id, @PathVariable Long destinationId) { + ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id); + if (node == null) { + return ResponseEntity.notFound().build(); + } + DestinationEntity existing = forwardNodeService.getDestinationById(node, destinationId); + if (existing == null) { + return ResponseEntity.notFound().build(); + } + destinationService.delete(existing); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/main/java/org/karnak/backend/controller/MonitoringController.java b/src/main/java/org/karnak/backend/controller/MonitoringController.java new file mode 100644 index 00000000..8ab0f105 --- /dev/null +++ b/src/main/java/org/karnak/backend/controller/MonitoringController.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2024-2026 Karnak Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.karnak.backend.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDateTime; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.karnak.backend.constant.EndPoint; +import org.karnak.backend.data.entity.TransferSeriesStatusEntity; +import org.karnak.backend.enums.TransferStatusType; +import org.karnak.backend.service.TransferMonitoringService; +import org.karnak.frontend.monitoring.component.ExportSettings; +import org.karnak.frontend.monitoring.component.TransferStatusFilter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Rest controller for querying and exporting DICOM transfer monitoring data + */ +@RestController +@RequestMapping(EndPoint.MONITORING_PATH) +@Tag(name = "Monitoring", description = "API Endpoints for Transfer Monitoring") +@Slf4j +public class MonitoringController { + + private static final int MAX_PAGE_SIZE = 1000; + + private final TransferMonitoringService transferMonitoringService; + + @Autowired + public MonitoringController(final TransferMonitoringService transferMonitoringService) { + this.transferMonitoringService = transferMonitoringService; + } + + @Operation(summary = "Query transfer status records", + description = "Returns a paginated list of aggregated per-series transfer records (one row per " + + "forward node/destination/series). All filter parameters are optional. " + + "status values: ALL, SENT, NOT_SENT, EXCLUDED, ERROR") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Transfer records returned") }) + @GetMapping(value = "/transfers", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getTransfers( + @Parameter(description = "Filter by Study UID (partial match)") @RequestParam( + required = false) String studyUid, + @Parameter(description = "Filter by Series UID (partial match)") @RequestParam( + required = false) String serieUid, + @Parameter(description = "Filter by status: ALL, SENT, NOT_SENT, EXCLUDED, ERROR") @RequestParam( + required = false, defaultValue = "ALL") String status, + @Parameter( + description = "Filter from date-time (ISO format: 2024-01-01T00:00:00), applies to the series' last activity") @RequestParam( + required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start, + @Parameter( + description = "Filter to date-time (ISO format: 2024-12-31T23:59:59), applies to the series' last activity") @RequestParam( + required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end, + @Parameter(description = "Page number (0-based)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "Page size") @RequestParam(defaultValue = "50") int size) { + + int safePage = Math.max(0, page); + int safeSize = Math.min(Math.max(1, size), MAX_PAGE_SIZE); + TransferStatusFilter filter = buildFilter(studyUid, serieUid, status, start, end); + Page result = transferMonitoringService.retrieveSeriesPageable(filter, + PageRequest.of(safePage, safeSize, Sort.by(Sort.Direction.DESC, "lastSeen"))); + return ResponseEntity.ok(Map.of("content", result.getContent(), "totalElements", result.getTotalElements(), + "totalPages", result.getTotalPages(), "page", safePage, "size", safeSize)); + } + + @Operation(summary = "Export transfer status records as CSV", + description = "Downloads a per-series CSV file of transfer records matching the filter. " + + "Optional ?delimiter=; param.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "CSV file returned"), + @ApiResponse(responseCode = "500", description = "Export error", content = @Content) }) + @GetMapping(value = "/transfers/export", produces = "text/csv") + public ResponseEntity exportTransfers(@RequestParam(required = false) String studyUid, + @RequestParam(required = false) String serieUid, + @RequestParam(required = false, defaultValue = "ALL") String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end, + @Parameter(description = "CSV delimiter character (default: ,)") @RequestParam( + required = false) String delimiter) { + try { + TransferStatusFilter filter = buildFilter(studyUid, serieUid, status, start, end); + ExportSettings exportSettings = new ExportSettings(); + if (delimiter != null && !delimiter.isEmpty()) { + exportSettings.setDelimiter(delimiter); + } + byte[] csv = transferMonitoringService.buildCsv(filter, exportSettings); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"monitoring-export.csv\"") + .contentType(MediaType.parseMediaType("text/csv")) + .body(csv); + } + catch (Exception e) { + log.error("Failed to export transfer status records", e); + return ResponseEntity.internalServerError().build(); + } + } + + @Operation(summary = "Count transfer status records matching the filter") + @GetMapping(value = "/transfers/count", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> countTransfers(@RequestParam(required = false) String studyUid, + @RequestParam(required = false) String serieUid, + @RequestParam(required = false, defaultValue = "ALL") String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end) { + TransferStatusFilter filter = buildFilter(studyUid, serieUid, status, start, end); + return ResponseEntity.ok(Map.of("count", transferMonitoringService.countSeries(filter))); + } + + private TransferStatusFilter buildFilter(String studyUid, String serieUid, String status, LocalDateTime start, + LocalDateTime end) { + TransferStatusFilter filter = new TransferStatusFilter(); + if (studyUid != null) { + filter.setStudyUid(studyUid); + } + if (serieUid != null) { + filter.setSerieUid(serieUid); + } + if (status != null) { + try { + filter.setTransferStatusType(TransferStatusType.valueOf(status.toUpperCase())); + } + catch (IllegalArgumentException ignored) { + // keep default ALL + } + } + filter.setStart(start); + filter.setEnd(end); + return filter; + } + +} diff --git a/src/main/java/org/karnak/backend/controller/ProfileController.java b/src/main/java/org/karnak/backend/controller/ProfileController.java new file mode 100644 index 00000000..65cc5118 --- /dev/null +++ b/src/main/java/org/karnak/backend/controller/ProfileController.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2024-2026 Karnak Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.karnak.backend.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.karnak.backend.constant.EndPoint; +import org.karnak.backend.data.entity.ProfileEntity; +import org.karnak.backend.model.profilebody.ProfilePipeBody; +import org.karnak.backend.service.profilepipe.ProfilePipeService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.Constructor; +import org.yaml.snakeyaml.error.YAMLException; + +/** + * Rest controller managing de-identification profiles + */ +@RestController +@RequestMapping(EndPoint.PROFILES_PATH) +@Tag(name = "Profile", description = "API Endpoints for De-identification Profiles") +public class ProfileController { + + private final ProfilePipeService profilePipeService; + + @Autowired + public ProfileController(final ProfilePipeService profilePipeService) { + this.profilePipeService = profilePipeService; + } + + @Operation(summary = "List all profiles", + description = "Returns id, name, version and minimumKarnakVersion for each profile") + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> getAllProfiles() { + List> result = profilePipeService.getAllProfiles().stream().map(p -> { + Map m = new HashMap<>(); + m.put("id", p.getId()); + m.put("name", p.getName() != null ? p.getName() : ""); + m.put("version", p.getVersion() != null ? p.getVersion() : ""); + m.put("minimumKarnakVersion", p.getMinimumKarnakVersion() != null ? p.getMinimumKarnakVersion() : ""); + m.put("byDefault", Boolean.TRUE.equals(p.getByDefault())); + return m; + }).toList(); + return result.isEmpty() ? ResponseEntity.noContent().build() : ResponseEntity.ok(result); + } + + @Operation(summary = "Upload a YAML profile", description = "Upload a YAML de-identification profile file") + @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "Profile uploaded and saved"), + @ApiResponse(responseCode = "400", description = "Invalid YAML, empty file, or unreadable upload", + content = @Content), + @ApiResponse(responseCode = "422", description = "Profile has validation errors", content = @Content) }) + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> uploadProfile(@RequestParam("file") MultipartFile file) { + if (file == null || file.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "Uploaded file is empty")); + } + try (InputStream inputStream = file.getInputStream()) { + Yaml yaml = new Yaml(new Constructor(ProfilePipeBody.class, new LoaderOptions())); + ProfilePipeBody profilePipeBody = yaml.load(inputStream); + if (profilePipeBody == null) { + return ResponseEntity.badRequest() + .body(Map.of("error", "YAML did not produce a profile (empty or malformed root)")); + } + var errors = profilePipeService.validateProfile(profilePipeBody); + boolean hasErrors = errors.stream().anyMatch(e -> e.getError() != null); + if (hasErrors) { + List errorMessages = errors.stream() + .filter(e -> e.getError() != null) + .map(e -> e.getProfileElement().getName() + ": " + e.getError()) + .toList(); + return ResponseEntity.unprocessableEntity().body(Map.of("errors", errorMessages)); + } + ProfileEntity saved = profilePipeService.saveProfilePipe(profilePipeBody, false); + return ResponseEntity.status(201).body(Map.of("id", saved.getId(), "name", saved.getName())); + } + catch (YAMLException e) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid YAML file: " + e.getMessage())); + } + catch (IOException e) { + return ResponseEntity.badRequest().body(Map.of("error", "Cannot read uploaded file")); + } + } + + @Operation(summary = "Get a profile by ID (JSON)") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Profile found"), + @ApiResponse(responseCode = "404", description = "Profile not found", content = @Content) }) + @GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getProfile(@PathVariable Long id) { + ProfileEntity profile = findById(id); + return profile == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(profile); + } + + @Operation(summary = "Download a profile as YAML") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Profile YAML file"), + @ApiResponse(responseCode = "404", description = "Profile not found", content = @Content), + @ApiResponse(responseCode = "500", description = "Serialization error", content = @Content) }) + @GetMapping(value = "/{id}/download", produces = "application/x-yaml") + public ResponseEntity downloadProfile(@PathVariable Long id) throws Exception { + ProfileEntity profile = findById(id); + if (profile == null) { + return ResponseEntity.notFound().build(); + } + ObjectMapper mapper = new ObjectMapper(new YAMLFactory().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)); + String yaml = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(profile); + String filename = (profile.getName() != null ? profile.getName() : "profile").replace(" ", "-") + ".yml"; + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .body(yaml); + } + + @Operation(summary = "Update profile metadata (name, version, minimumKarnakVersion)") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Profile updated"), + @ApiResponse(responseCode = "404", description = "Profile not found", content = @Content) }) + @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> updateProfile(@PathVariable Long id, + @RequestBody Map body) { + ProfileEntity profile = findById(id); + if (profile == null) { + return ResponseEntity.notFound().build(); + } + if (body.containsKey("name")) { + profile.setName(body.get("name")); + } + if (body.containsKey("version")) { + profile.setVersion(body.get("version")); + } + if (body.containsKey("minimumKarnakVersion")) { + profile.setMinimumKarnakVersion(body.get("minimumKarnakVersion")); + } + profilePipeService.updateProfile(profile); + return ResponseEntity.ok(Map.of("id", profile.getId(), "name", profile.getName())); + } + + @Operation(summary = "Delete a profile") + @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "Profile deleted"), + @ApiResponse(responseCode = "404", description = "Profile not found", content = @Content) }) + @DeleteMapping(value = "/{id}") + public ResponseEntity deleteProfile(@PathVariable Long id) { + ProfileEntity profile = findById(id); + if (profile == null) { + return ResponseEntity.notFound().build(); + } + profilePipeService.deleteProfile(profile); + return ResponseEntity.noContent().build(); + } + + private ProfileEntity findById(Long id) { + return profilePipeService.getAllProfiles().stream().filter(p -> p.getId().equals(id)).findFirst().orElse(null); + } + +} diff --git a/src/main/java/org/karnak/backend/controller/ProjectController.java b/src/main/java/org/karnak/backend/controller/ProjectController.java new file mode 100644 index 00000000..bf85925f --- /dev/null +++ b/src/main/java/org/karnak/backend/controller/ProjectController.java @@ -0,0 +1,415 @@ +/* + * Copyright (c) 2024-2026 Karnak Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.karnak.backend.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.Collection; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import org.karnak.backend.cache.Patient; +import org.karnak.backend.cache.PatientClient; +import org.karnak.backend.constant.EndPoint; +import org.karnak.backend.data.entity.ProfileEntity; +import org.karnak.backend.data.entity.ProjectEntity; +import org.karnak.backend.data.entity.SecretEntity; +import org.karnak.backend.model.profilepipe.HMAC; +import org.karnak.backend.service.ProjectService; +import org.karnak.backend.service.profilepipe.ProfilePipeService; +import org.karnak.backend.util.PatientClientUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Rest controller managing projects (de-identification configuration) and their external + * IDs + */ +@RestController +@RequestMapping(EndPoint.PROJECTS_PATH) +@Tag(name = "Project", description = "API Endpoints for Projects and External IDs") +public class ProjectController { + + private final ProjectService projectService; + + private final ProfilePipeService profilePipeService; + + private final PatientClient patientClient; + + @Autowired + public ProjectController(final ProjectService projectService, final ProfilePipeService profilePipeService, + @Qualifier("patientClient") final PatientClient patientClient) { + this.projectService = projectService; + this.profilePipeService = profilePipeService; + this.patientClient = patientClient; + } + + // ======== Projects ======== + + @Operation(summary = "List all projects") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Projects found"), + @ApiResponse(responseCode = "204", description = "No projects", content = @Content) }) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getAllProjects() { + List projects = projectService.getAllProjects(); + return projects.isEmpty() ? ResponseEntity.noContent().build() : ResponseEntity.ok(projects); + } + + @Operation(summary = "Create a project", description = "Body: {\"name\": \"...\", \"profileId\": 1 (optional)}") + @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "Project created"), + @ApiResponse(responseCode = "400", description = "Missing name or invalid profileId", content = @Content), + @ApiResponse(responseCode = "404", description = "Profile not found", content = @Content) }) + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createProject(@RequestBody Map body) { + String name = (String) body.get("name"); + if (name == null || name.isBlank()) { + return ResponseEntity.badRequest().build(); + } + ProjectEntity project = new ProjectEntity(); + project.setName(name); + if (body.get("profileId") != null) { + Long profileId; + try { + profileId = Long.valueOf(body.get("profileId").toString()); + } + catch (NumberFormatException e) { + return ResponseEntity.badRequest().build(); + } + ProfileEntity profile = findProfileById(profileId); + if (profile == null) { + return ResponseEntity.notFound().build(); + } + project.setProfileEntity(profile); + } + ProjectEntity saved = projectService.save(project); + return ResponseEntity.status(201).body(saved); + } + + private ProfileEntity findProfileById(Long profileId) { + return profilePipeService.getAllProfiles() + .stream() + .filter(p -> p.getId().equals(profileId)) + .findFirst() + .orElse(null); + } + + @Operation(summary = "Get a project by ID") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Project found"), + @ApiResponse(responseCode = "404", description = "Project not found", content = @Content) }) + @GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getProject(@PathVariable Long id) { + ProjectEntity project = projectService.retrieveProject(id); + return project == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(project); + } + + @Operation(summary = "Update a project", + description = "Body: {\"name\": \"...\", \"profileId\": 1 (optional, null to unset)}") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Project updated"), + @ApiResponse(responseCode = "404", description = "Project or profile not found", content = @Content) }) + @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity updateProject(@PathVariable Long id, @RequestBody Map body) { + ProjectEntity project = projectService.retrieveProject(id); + if (project == null) { + return ResponseEntity.notFound().build(); + } + if (body.containsKey("name")) { + project.setName((String) body.get("name")); + } + if (body.containsKey("profileId")) { + Object profileIdRaw = body.get("profileId"); + if (profileIdRaw == null) { + project.setProfileEntity(null); + } + else { + Long profileId; + try { + profileId = Long.valueOf(profileIdRaw.toString()); + } + catch (NumberFormatException e) { + return ResponseEntity.badRequest().build(); + } + ProfileEntity profile = findProfileById(profileId); + if (profile == null) { + return ResponseEntity.notFound().build(); + } + project.setProfileEntity(profile); + } + } + projectService.update(project); + return ResponseEntity.ok(project); + } + + @Operation(summary = "Delete a project") + @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "Project deleted"), + @ApiResponse(responseCode = "404", description = "Project not found", content = @Content) }) + @DeleteMapping(value = "/{id}") + public ResponseEntity deleteProject(@PathVariable Long id) { + ProjectEntity project = projectService.retrieveProject(id); + if (project == null) { + return ResponseEntity.notFound().build(); + } + projectService.remove(project); + return ResponseEntity.noContent().build(); + } + + // ======== Secrets ======== + + @Operation(summary = "Add or generate a HMAC secret for a project", + description = "Body: {\"hexKey\": \"...\"} — omit hexKey for auto-generation. " + + "The key must be a 32-char hex string (16 bytes).") + @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "Secret added"), + @ApiResponse(responseCode = "400", description = "Invalid hex key format", content = @Content), + @ApiResponse(responseCode = "404", description = "Project not found", content = @Content) }) + @PostMapping(value = "/{id}/secrets", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> addSecret(@PathVariable Long id, + @RequestBody(required = false) Map body) { + ProjectEntity project = projectService.retrieveProject(id); + if (project == null) { + return ResponseEntity.notFound().build(); + } + byte[] keyBytes; + String hexInput = body != null ? body.get("hexKey") : null; + if (hexInput != null && !hexInput.isBlank()) { + String cleaned = hexInput.replace("-", ""); + if (cleaned.length() != HMAC.KEY_BYTE_LENGTH * 2) { + return ResponseEntity.badRequest() + .body(Map.of("error", + "Invalid hex key: expected " + (HMAC.KEY_BYTE_LENGTH * 2) + " hex characters")); + } + try { + keyBytes = HexFormat.of().parseHex(cleaned); + } + catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid hex key: " + e.getMessage())); + } + } + else { + keyBytes = HMAC.generateRandomKey(); + } + SecretEntity secret = new SecretEntity(keyBytes); + project.addActiveSecretEntity(secret); + ProjectEntity updated = projectService.save(project); + SecretEntity activeSecret = updated.retrieveActiveSecret(); + String hexKey = HMAC.byteToHex(activeSecret.getSecretKey()); + return ResponseEntity.status(201) + .body(Map.of("projectId", id, "hexKey", hexKey, "displayKey", HMAC.showHexKey(hexKey), "active", true)); + } + + // ======== External IDs ======== + + @Operation(summary = "List external ID mappings for a project", + description = "Returns the in-memory pseudonym→patientId cache for the given project") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Patients found"), + @ApiResponse(responseCode = "404", description = "Project not found", content = @Content) }) + @GetMapping(value = "/{id}/external-ids", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getExternalIds(@PathVariable Long id) { + if (projectService.retrieveProject(id) == null) { + return ResponseEntity.notFound().build(); + } + List patients = patientClient.getAll().stream().filter(p -> id.equals(p.getProjectID())).toList(); + return ResponseEntity.ok(patients); + } + + @Operation(summary = "Add a patient external ID mapping", + description = "Body: {\"pseudonym\": \"...\", \"patientId\": \"...\", \"patientFirstName\": \"...\"," + + " \"patientLastName\": \"...\", \"issuerOfPatientId\": \"...\"," + + " \"patientBirthDate\": \"YYYY-MM-DD\" (opt), \"patientSex\": \"M/F\" (opt)}") + @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "Patient mapping added"), + @ApiResponse(responseCode = "400", description = "Missing required fields", content = @Content), + @ApiResponse(responseCode = "404", description = "Project not found", content = @Content), + @ApiResponse(responseCode = "409", description = "Patient already exists", content = @Content) }) + @PostMapping(value = "/{id}/external-ids", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity addExternalId(@PathVariable Long id, @RequestBody Map body) { + if (projectService.retrieveProject(id) == null) { + return ResponseEntity.notFound().build(); + } + String pseudonym = body.get("pseudonym"); + String patientId = body.get("patientId"); + if (pseudonym == null || pseudonym.isBlank() || patientId == null || patientId.isBlank()) { + return ResponseEntity.badRequest().build(); + } + String firstName = body.getOrDefault("patientFirstName", ""); + String lastName = body.getOrDefault("patientLastName", ""); + String issuer = body.getOrDefault("issuerOfPatientId", ""); + LocalDate birthDate; + try { + birthDate = parseOptionalDate(body.get("patientBirthDate")); + } + catch (DateTimeParseException e) { + return ResponseEntity.badRequest().build(); + } + String sex = body.getOrDefault("patientSex", ""); + Patient patient = new Patient(pseudonym, patientId, firstName, lastName, birthDate, sex, issuer); + patient.setProjectID(id); + String key = PatientClientUtil.generateKey(patient, id); + Patient existing = patientClient.put(key, patient); + if (existing != null) { + return ResponseEntity.status(409).build(); + } + return ResponseEntity.status(201).body(patient); + } + + private static LocalDate parseOptionalDate(String value) { + if (value == null || value.isBlank()) { + return null; + } + return LocalDate.parse(value); + } + + @Operation(summary = "Update a patient external ID mapping", + description = "patientId and issuerOfPatientId identify the existing record. " + + "Body contains fields to update.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Patient updated"), + @ApiResponse(responseCode = "404", description = "Project or patient not found", content = @Content) }) + @PutMapping(value = "/{id}/external-ids/{patientId}", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity updateExternalId(@PathVariable Long id, @PathVariable String patientId, + @RequestParam(defaultValue = "") String issuerId, @RequestBody Map body) { + if (projectService.retrieveProject(id) == null) { + return ResponseEntity.notFound().build(); + } + String oldKey = PatientClientUtil.generateKey(patientId, issuerId) + id; + Patient existing = patientClient.get(oldKey); + if (existing == null) { + return ResponseEntity.notFound().build(); + } + patientClient.remove(oldKey); + if (body.containsKey("pseudonym")) { + existing.setPseudonym(body.get("pseudonym")); + } + if (body.containsKey("patientId")) { + existing.setPatientId(body.get("patientId")); + } + if (body.containsKey("patientFirstName")) { + existing.updatePatientFirstName(body.get("patientFirstName")); + } + if (body.containsKey("patientLastName")) { + existing.updatePatientLastName(body.get("patientLastName")); + } + if (body.containsKey("issuerOfPatientId")) { + existing.setIssuerOfPatientId(body.get("issuerOfPatientId")); + } + if (body.containsKey("patientBirthDate")) { + try { + existing.setPatientBirthDate(parseOptionalDate(body.get("patientBirthDate"))); + } + catch (DateTimeParseException e) { + // Re-insert the original record before bailing so the in-memory cache + // state + // is restored — the remove() above must not be observed as a side-effect + // of + // a malformed update payload. + patientClient.put(oldKey, existing); + return ResponseEntity.badRequest().build(); + } + } + if (body.containsKey("patientSex")) { + existing.setPatientSex(body.get("patientSex")); + } + patientClient.put(PatientClientUtil.generateKey(existing, id), existing); + return ResponseEntity.ok(existing); + } + + @Operation(summary = "Delete a patient external ID mapping", + description = "patientId path var; use ?issuerId= to disambiguate if needed") + @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "Patient deleted"), + @ApiResponse(responseCode = "404", description = "Project or patient not found", content = @Content) }) + @DeleteMapping(value = "/{id}/external-ids/{patientId}") + public ResponseEntity deleteExternalId(@PathVariable Long id, @PathVariable String patientId, + @RequestParam(defaultValue = "") String issuerId) { + if (projectService.retrieveProject(id) == null) { + return ResponseEntity.notFound().build(); + } + String key = PatientClientUtil.generateKey(patientId, issuerId) + id; + if (patientClient.get(key) == null) { + return ResponseEntity.notFound().build(); + } + patientClient.remove(key); + return ResponseEntity.noContent().build(); + } + + @Operation(summary = "Delete all patient external ID mappings for a project") + @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "All patients deleted"), + @ApiResponse(responseCode = "404", description = "Project not found", content = @Content) }) + @DeleteMapping(value = "/{id}/external-ids") + public ResponseEntity deleteAllExternalIds(@PathVariable Long id) { + if (projectService.retrieveProject(id) == null) { + return ResponseEntity.notFound().build(); + } + List patients = patientClient.getAll().stream().filter(p -> id.equals(p.getProjectID())).toList(); + patients.forEach(p -> patientClient.remove(PatientClientUtil.generateKey(p, id))); + return ResponseEntity.noContent().build(); + } + + @Operation(summary = "Bulk import patient external ID mappings", + description = "Body: JSON array of patient objects. Each must have pseudonym and patientId.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Import complete with result summary"), + @ApiResponse(responseCode = "404", description = "Project not found", content = @Content) }) + @PostMapping(value = "/{id}/external-ids/import", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> importExternalIds(@PathVariable Long id, + @RequestBody List> patients) { + if (projectService.retrieveProject(id) == null) { + return ResponseEntity.notFound().build(); + } + int added = 0; + int skipped = 0; + for (Map p : patients) { + String pseudonym = p.get("pseudonym"); + String patientId = p.get("patientId"); + if (pseudonym == null || pseudonym.isBlank() || patientId == null || patientId.isBlank()) { + skipped++; + continue; + } + String issuer = p.getOrDefault("issuerOfPatientId", ""); + String firstName = p.getOrDefault("patientFirstName", ""); + String lastName = p.getOrDefault("patientLastName", ""); + LocalDate birthDate; + try { + birthDate = parseOptionalDate(p.get("patientBirthDate")); + } + catch (DateTimeParseException e) { + skipped++; + continue; + } + String sex = p.getOrDefault("patientSex", ""); + Patient patient = new Patient(pseudonym, patientId, firstName, lastName, birthDate, sex, issuer); + patient.setProjectID(id); + String key = PatientClientUtil.generateKey(patient, id); + if (patientClient.put(key, patient) != null) { + skipped++; + } + else { + added++; + } + } + return ResponseEntity.ok(Map.of("added", added, "skipped", skipped)); + } + +} diff --git a/src/main/java/org/karnak/backend/data/entity/AuthConfigEntity.java b/src/main/java/org/karnak/backend/data/entity/AuthConfigEntity.java index ec8b5328..0b9e23a3 100644 --- a/src/main/java/org/karnak/backend/data/entity/AuthConfigEntity.java +++ b/src/main/java/org/karnak/backend/data/entity/AuthConfigEntity.java @@ -10,6 +10,8 @@ package org.karnak.backend.data.entity; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonProperty.Access; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -48,6 +50,7 @@ public class AuthConfigEntity implements Serializable { read = "pgp_sym_decrypt(" + " client_secret, " + " current_setting('encryption.key')" + ")", write = "pgp_sym_encrypt( " + " ?, " + " current_setting('encryption.key')" + ") ") @Column(columnDefinition = "bytea") + @JsonProperty(access = Access.WRITE_ONLY) private String clientSecret; @ColumnTransformer(read = "pgp_sym_decrypt(" + " client_id, " + " current_setting('encryption.key')" + ")", diff --git a/src/main/java/org/karnak/backend/data/entity/DestinationEntity.java b/src/main/java/org/karnak/backend/data/entity/DestinationEntity.java index 607f0425..8dbbabf7 100644 --- a/src/main/java/org/karnak/backend/data/entity/DestinationEntity.java +++ b/src/main/java/org/karnak/backend/data/entity/DestinationEntity.java @@ -10,6 +10,7 @@ package org.karnak.backend.data.entity; import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSetter; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -300,7 +301,7 @@ public static DestinationEntity ofStow(String description, String url, String he return destinationEntity; } - @JsonGetter("forwardNode") + @JsonIgnore public ForwardNodeEntity getForwardNodeEntity() { return forwardNodeEntity; } diff --git a/src/main/java/org/karnak/backend/data/entity/DicomSourceNodeEntity.java b/src/main/java/org/karnak/backend/data/entity/DicomSourceNodeEntity.java index 3d67181b..8bdf18a7 100644 --- a/src/main/java/org/karnak/backend/data/entity/DicomSourceNodeEntity.java +++ b/src/main/java/org/karnak/backend/data/entity/DicomSourceNodeEntity.java @@ -9,6 +9,7 @@ */ package org.karnak.backend.data.entity; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -56,6 +57,7 @@ public class DicomSourceNodeEntity implements Serializable { @ManyToOne @JoinColumn(name = "forward_node_id") + @JsonIgnore private ForwardNodeEntity forwardNodeEntity; public DicomSourceNodeEntity() { diff --git a/src/main/java/org/karnak/backend/data/entity/ProjectEntity.java b/src/main/java/org/karnak/backend/data/entity/ProjectEntity.java index 54681397..57ec09c3 100644 --- a/src/main/java/org/karnak/backend/data/entity/ProjectEntity.java +++ b/src/main/java/org/karnak/backend/data/entity/ProjectEntity.java @@ -9,6 +9,7 @@ */ package org.karnak.backend.data.entity; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -52,6 +53,7 @@ public class ProjectEntity implements Serializable { private List secretEntities; @OneToMany(mappedBy = "deIdentificationProjectEntity", fetch = FetchType.EAGER) + @JsonIgnore private List destinationEntities; @ManyToOne @@ -81,6 +83,7 @@ public ProjectEntity() { * @param secretEntity Secret to add */ public void addActiveSecretEntity(SecretEntity secretEntity) { + secretEntity.setProjectEntity(this); applyActiveSecret(secretEntity); secretEntities.add(secretEntity); } diff --git a/src/main/java/org/karnak/backend/data/entity/SecretEntity.java b/src/main/java/org/karnak/backend/data/entity/SecretEntity.java index 496fa834..ae993a50 100644 --- a/src/main/java/org/karnak/backend/data/entity/SecretEntity.java +++ b/src/main/java/org/karnak/backend/data/entity/SecretEntity.java @@ -9,6 +9,7 @@ */ package org.karnak.backend.data.entity; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -42,8 +43,10 @@ public class SecretEntity implements Serializable { @ManyToOne @JoinColumn(name = "project_id") + @JsonIgnore private ProjectEntity projectEntity; + @JsonIgnore private byte[] secretKey; private LocalDateTime creationDate; diff --git a/src/main/java/org/karnak/backend/service/ProjectService.java b/src/main/java/org/karnak/backend/service/ProjectService.java index 65353ff0..94e5c4e9 100644 --- a/src/main/java/org/karnak/backend/service/ProjectService.java +++ b/src/main/java/org/karnak/backend/service/ProjectService.java @@ -24,6 +24,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; /** * Project service @@ -149,7 +150,19 @@ private void updateDestinations(ProjectEntity projectEntity) { * Remove project * @param projectEntity Project to remove */ + @Transactional public void remove(ProjectEntity projectEntity) { + // Clear FK references in destinations before deleting to avoid constraint + // violations. Destinations using this project for de-identification or tag + // morphing become pass-through. + for (DestinationEntity destinationEntity : projectEntity.getDestinationEntities()) { + destinationEntity.setDeIdentificationProjectEntity(null); + destinationRepo.save(destinationEntity); + } + for (DestinationEntity destinationEntity : destinationRepo.findByTagMorphingProjectEntity(projectEntity)) { + destinationEntity.setTagMorphingProjectEntity(null); + destinationRepo.save(destinationEntity); + } projectRepo.deleteById(projectEntity.getId()); projectRepo.flush(); } diff --git a/src/main/java/org/karnak/backend/service/TransferMonitoringService.java b/src/main/java/org/karnak/backend/service/TransferMonitoringService.java index a574b1c7..45b29e47 100644 --- a/src/main/java/org/karnak/backend/service/TransferMonitoringService.java +++ b/src/main/java/org/karnak/backend/service/TransferMonitoringService.java @@ -38,6 +38,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.event.EventListener; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -121,6 +123,21 @@ public List retrieveSeries(TransferStatusFilter filt return filter.hasFilter() ? seriesRepo.findAll(new TransferSeriesSpecification(filter)) : seriesRepo.findAll(); } + /** + * Retrieve a page of series rows matching the filter, with their error reasons filled + * in. + */ + public Page retrieveSeriesPageable(TransferStatusFilter filter, Pageable pageable) { + Page page = seriesRepo.findAll(new TransferSeriesSpecification(filter), pageable); + populateReasons(page.getContent()); + return page; + } + + /** Count of series rows matching the filter. */ + public long countSeries(TransferStatusFilter filter) { + return seriesRepo.count(new TransferSeriesSpecification(filter)); + } + /** * Build a per-series CSV for the matching rows (one row per destination/study/series, * with counts and the joined error reasons). @@ -148,7 +165,7 @@ public byte[] buildCsv(TransferStatusFilter filter, ExportSettings exportSetting } /** Fills each row's transient {@code reasons} with its distinct error reasons. */ - private void populateReasons(List rows) { + public void populateReasons(List rows) { if (rows.isEmpty()) { return; } diff --git a/src/test/java/org/karnak/backend/controller/AuthConfigControllerTest.java b/src/test/java/org/karnak/backend/controller/AuthConfigControllerTest.java new file mode 100644 index 00000000..1bce06cd --- /dev/null +++ b/src/test/java/org/karnak/backend/controller/AuthConfigControllerTest.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2024-2026 Karnak Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.karnak.backend.controller; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.karnak.backend.data.entity.AuthConfigEntity; +import org.karnak.backend.data.repo.AuthConfigRepo; +import org.karnak.backend.enums.AuthConfigType; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class AuthConfigControllerTest { + + private AuthConfigRepo authConfigRepo; + + private MockMvc mockMvc; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + authConfigRepo = Mockito.mock(AuthConfigRepo.class); + mockMvc = MockMvcBuilders.standaloneSetup(new AuthConfigController(authConfigRepo)).build(); + } + + @Test + void getAllAuthConfigs_when_empty_returns_no_content() throws Exception { + when(authConfigRepo.findAll()).thenReturn(Collections.emptyList()); + mockMvc.perform(get("/api/auth-configs")).andExpect(status().isNoContent()); + } + + @Test + void getAllAuthConfigs_when_present_returns_list_and_never_exposes_clientSecret() throws Exception { + AuthConfigEntity cfg = new AuthConfigEntity(); + cfg.setId(1L); + cfg.setCode("keycloak"); + cfg.setClientId("client"); + cfg.setClientSecret("super-secret"); + cfg.setAccessTokenUrl("https://idp/token"); + cfg.setScope("openid"); + cfg.setAuthConfigType(AuthConfigType.OAUTH2); + when(authConfigRepo.findAll()).thenReturn(List.of(cfg)); + + MvcResult result = mockMvc.perform(get("/api/auth-configs")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].code").value("keycloak")) + .andExpect(jsonPath("$[0].clientId").value("client")) + .andReturn(); + + String body = result.getResponse().getContentAsString(); + assertFalse(body.contains("super-secret"), "clientSecret must not be serialized in API responses: " + body); + } + + @Test + void createAuthConfig_rejects_blank_code() throws Exception { + AuthConfigEntity cfg = new AuthConfigEntity(); + cfg.setCode(""); + + mockMvc + .perform(post("/api/auth-configs").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(cfg))) + .andExpect(status().isBadRequest()); + + verify(authConfigRepo, never()).save(any()); + } + + @Test + void createAuthConfig_conflict_when_code_exists() throws Exception { + AuthConfigEntity existing = new AuthConfigEntity(); + existing.setCode("dup"); + when(authConfigRepo.findByCode("dup")).thenReturn(existing); + + AuthConfigEntity incoming = new AuthConfigEntity(); + incoming.setCode("dup"); + + mockMvc + .perform(post("/api/auth-configs").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(incoming))) + .andExpect(status().isConflict()); + + verify(authConfigRepo, never()).save(any()); + } + + @Test + void createAuthConfig_defaults_auth_config_type_when_missing() throws Exception { + when(authConfigRepo.findByCode("new")).thenReturn(null); + when(authConfigRepo.save(any(AuthConfigEntity.class))).thenAnswer(inv -> { + AuthConfigEntity arg = inv.getArgument(0); + arg.setId(42L); + return arg; + }); + + mockMvc + .perform(post("/api/auth-configs").contentType(MediaType.APPLICATION_JSON) + .content("{\"code\": \"new\", \"clientId\": \"c\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.authConfigType").value("OAUTH2")); + } + + @Test + void getAuthConfig_returns_404_when_missing() throws Exception { + when(authConfigRepo.findByCode("missing")).thenReturn(null); + mockMvc.perform(get("/api/auth-configs/missing")).andExpect(status().isNotFound()); + } + + @Test + void updateAuthConfig_only_updates_provided_fields() throws Exception { + AuthConfigEntity existing = new AuthConfigEntity(); + existing.setCode("k"); + existing.setClientId("old-id"); + existing.setScope("old-scope"); + existing.setClientSecret("old-secret"); + when(authConfigRepo.findByCode("k")).thenReturn(existing); + when(authConfigRepo.save(any(AuthConfigEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + + mockMvc + .perform(put("/api/auth-configs/k").contentType(MediaType.APPLICATION_JSON) + .content("{\"scope\": \"new-scope\"}")) + .andExpect(status().isOk()); + + // existing entity was mutated in place; clientId should be unchanged + // (verified by Mockito: the same object was saved with new-scope and old-id) + verify(authConfigRepo).save(eq(existing)); + assertFalse("old-scope".equals(existing.getScope()), "scope should be updated"); + } + + @Test + void deleteAuthConfig_returns_204_when_present() throws Exception { + AuthConfigEntity existing = new AuthConfigEntity(); + existing.setCode("k"); + when(authConfigRepo.findByCode("k")).thenReturn(existing); + + mockMvc.perform(delete("/api/auth-configs/k")).andExpect(status().isNoContent()); + verify(authConfigRepo).delete(existing); + } + + @Test + void deleteAuthConfig_returns_404_when_absent() throws Exception { + when(authConfigRepo.findByCode("nope")).thenReturn(null); + mockMvc.perform(delete("/api/auth-configs/nope")).andExpect(status().isNotFound()); + verify(authConfigRepo, never()).delete(any()); + } + +} diff --git a/src/test/java/org/karnak/backend/controller/ForwardNodeControllerTest.java b/src/test/java/org/karnak/backend/controller/ForwardNodeControllerTest.java new file mode 100644 index 00000000..839110ac --- /dev/null +++ b/src/test/java/org/karnak/backend/controller/ForwardNodeControllerTest.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2024-2026 Karnak Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.karnak.backend.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.karnak.backend.data.entity.DestinationEntity; +import org.karnak.backend.data.entity.DicomSourceNodeEntity; +import org.karnak.backend.data.entity.ForwardNodeEntity; +import org.karnak.backend.service.DestinationService; +import org.karnak.backend.service.ForwardNodeAPIService; +import org.karnak.backend.service.ForwardNodeService; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class ForwardNodeControllerTest { + + private ForwardNodeAPIService forwardNodeAPIService; + + private ForwardNodeService forwardNodeService; + + private DestinationService destinationService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + forwardNodeAPIService = Mockito.mock(ForwardNodeAPIService.class); + forwardNodeService = Mockito.mock(ForwardNodeService.class); + destinationService = Mockito.mock(DestinationService.class); + mockMvc = MockMvcBuilders + .standaloneSetup(new ForwardNodeController(forwardNodeAPIService, forwardNodeService, destinationService)) + .build(); + } + + @Test + void getAllForwardNodes_returns_204_when_empty() throws Exception { + when(forwardNodeService.getAllForwardNodes()).thenReturn(List.of()); + mockMvc.perform(get("/api/forward-nodes")).andExpect(status().isNoContent()); + } + + @Test + void getAllForwardNodes_returns_200_with_list() throws Exception { + ForwardNodeEntity node = new ForwardNodeEntity("MY_GATEWAY"); + node.setId(1L); + when(forwardNodeService.getAllForwardNodes()).thenReturn(List.of(node)); + mockMvc.perform(get("/api/forward-nodes")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].fwdAeTitle").value("MY_GATEWAY")); + } + + @Test + void createForwardNode_rejects_missing_aetitle() throws Exception { + mockMvc + .perform(post("/api/forward-nodes").contentType(MediaType.APPLICATION_JSON) + .content("{\"fwdDescription\": \"no aet\"}")) + .andExpect(status().isBadRequest()); + + verify(forwardNodeAPIService, never()).addForwardNode(any()); + } + + @Test + void createForwardNode_returns_409_when_aetitle_exists() throws Exception { + ForwardNodeEntity existing = new ForwardNodeEntity("DUP"); + when(forwardNodeService.getAllForwardNodes()).thenReturn(List.of(existing)); + + mockMvc + .perform(post("/api/forward-nodes").contentType(MediaType.APPLICATION_JSON) + .content("{\"fwdAeTitle\": \"DUP\"}")) + .andExpect(status().isConflict()); + + verify(forwardNodeAPIService, never()).addForwardNode(any()); + } + + @Test + void createForwardNode_returns_201() throws Exception { + when(forwardNodeService.getAllForwardNodes()).thenReturn(List.of()); + + mockMvc + .perform(post("/api/forward-nodes").contentType(MediaType.APPLICATION_JSON) + .content("{\"fwdAeTitle\": \"GW1\", \"fwdDescription\": \"main\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.fwdAeTitle").value("GW1")); + + verify(forwardNodeAPIService).addForwardNode(any(ForwardNodeEntity.class)); + } + + @Test + void getForwardNode_returns_404_when_missing() throws Exception { + when(forwardNodeAPIService.getForwardNodeById(99L)).thenReturn(null); + mockMvc.perform(get("/api/forward-nodes/99")).andExpect(status().isNotFound()); + } + + /** + * Regression test for a previously found bug: a PUT with a partial body was wiping + * out the existing source nodes and destinations via cascade=ALL/orphanRemoval. The + * controller must merge fwdAeTitle/fwdDescription onto the existing entity and pass + * the existing entity to the service so children survive. + */ + @Test + void updateForwardNode_preserves_source_nodes_and_destinations() throws Exception { + ForwardNodeEntity existing = new ForwardNodeEntity("GW1"); + existing.setId(1L); + existing.setFwdDescription("old"); + DicomSourceNodeEntity src = new DicomSourceNodeEntity(); + src.setId(10L); + existing.addSourceNode(src); + DestinationEntity dest = DestinationEntity.ofDicomEmpty(); + dest.setId(20L); + existing.addDestination(dest); + when(forwardNodeAPIService.getForwardNodeById(1L)).thenReturn(existing); + + mockMvc + .perform(put("/api/forward-nodes/1").contentType(MediaType.APPLICATION_JSON) + .content("{\"fwdAeTitle\": \"GW1\", \"fwdDescription\": \"new\"}")) + .andExpect(status().isOk()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ForwardNodeEntity.class); + verify(forwardNodeAPIService).updateForwardNode(captor.capture()); + ForwardNodeEntity saved = captor.getValue(); + assertEquals(1L, saved.getId()); + assertEquals("new", saved.getFwdDescription()); + assertEquals(1, saved.getSourceNodes().size(), "source nodes must be preserved"); + assertEquals(1, saved.getDestinationEntities().size(), "destinations must be preserved"); + assertNotNull(saved.getSourceNodes().iterator().next().getId()); + assertNotNull(saved.getDestinationEntities().iterator().next().getId()); + } + + @Test + void updateForwardNode_returns_404_when_missing() throws Exception { + when(forwardNodeAPIService.getForwardNodeById(2L)).thenReturn(null); + + mockMvc + .perform(put("/api/forward-nodes/2").contentType(MediaType.APPLICATION_JSON) + .content("{\"fwdAeTitle\": \"X\"}")) + .andExpect(status().isNotFound()); + } + + @Test + void deleteForwardNode_returns_204() throws Exception { + ForwardNodeEntity existing = new ForwardNodeEntity("X"); + existing.setId(3L); + when(forwardNodeAPIService.getForwardNodeById(3L)).thenReturn(existing); + + mockMvc.perform(delete("/api/forward-nodes/3")).andExpect(status().isNoContent()); + verify(forwardNodeAPIService).deleteForwardNode(existing); + } + + @Test + void addSourceNode_returns_404_when_forward_node_missing() throws Exception { + when(forwardNodeAPIService.getForwardNodeById(7L)).thenReturn(null); + mockMvc + .perform(post("/api/forward-nodes/7/source-nodes").contentType(MediaType.APPLICATION_JSON) + .content("{\"aeTitle\": \"SRC\"}")) + .andExpect(status().isNotFound()); + } + + @Test + void addDestination_returns_201_and_returns_saved_entity() throws Exception { + ForwardNodeEntity node = new ForwardNodeEntity("GW"); + node.setId(5L); + when(forwardNodeAPIService.getForwardNodeById(5L)).thenReturn(node); + when(destinationService.save(any(), any(DestinationEntity.class))).thenAnswer(inv -> { + DestinationEntity d = inv.getArgument(1); + d.setId(123L); + return d; + }); + + mockMvc + .perform(post("/api/forward-nodes/5/destinations").contentType(MediaType.APPLICATION_JSON) + .content("{\"destinationType\": \"dicom\", \"aeTitle\": \"D\", \"hostname\": \"h\", \"port\": 11112}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(123)); + } + + @Test + void deleteDestination_returns_404_when_destination_missing() throws Exception { + ForwardNodeEntity node = new ForwardNodeEntity("GW"); + node.setId(5L); + when(forwardNodeAPIService.getForwardNodeById(5L)).thenReturn(node); + when(forwardNodeService.getDestinationById(node, 99L)).thenReturn(null); + + mockMvc.perform(delete("/api/forward-nodes/5/destinations/99")).andExpect(status().isNotFound()); + } + +} diff --git a/src/test/java/org/karnak/backend/controller/MonitoringControllerTest.java b/src/test/java/org/karnak/backend/controller/MonitoringControllerTest.java new file mode 100644 index 00000000..601b6457 --- /dev/null +++ b/src/test/java/org/karnak/backend/controller/MonitoringControllerTest.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2024-2026 Karnak Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.karnak.backend.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.karnak.backend.data.entity.TransferSeriesStatusEntity; +import org.karnak.backend.enums.TransferStatusType; +import org.karnak.backend.service.TransferMonitoringService; +import org.karnak.frontend.monitoring.component.TransferStatusFilter; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class MonitoringControllerTest { + + private TransferMonitoringService transferMonitoringService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + transferMonitoringService = Mockito.mock(TransferMonitoringService.class); + mockMvc = MockMvcBuilders.standaloneSetup(new MonitoringController(transferMonitoringService)).build(); + } + + @Test + void getTransfers_returns_empty_page_with_metadata() throws Exception { + Page page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 50), 0); + when(transferMonitoringService.retrieveSeriesPageable(any(), any())).thenReturn(page); + + mockMvc.perform(get("/api/monitoring/transfers")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(0)) + .andExpect(jsonPath("$.totalElements").value(0)) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(50)); + } + + @Test + void getTransfers_clamps_excessive_page_size() throws Exception { + Page page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 1000), 0); + when(transferMonitoringService.retrieveSeriesPageable(any(), any())).thenReturn(page); + + mockMvc.perform(get("/api/monitoring/transfers").param("size", "999999")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.size").value(1000)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); + verify(transferMonitoringService).retrieveSeriesPageable(any(), captor.capture()); + // Clamped to MAX_PAGE_SIZE (1000) + org.junit.jupiter.api.Assertions.assertEquals(1000, captor.getValue().getPageSize()); + } + + @Test + void getTransfers_clamps_negative_page() throws Exception { + Page page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 50), 0); + when(transferMonitoringService.retrieveSeriesPageable(any(), any())).thenReturn(page); + + mockMvc.perform(get("/api/monitoring/transfers").param("page", "-5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page").value(0)); + } + + @Test + void getTransfers_invalid_status_defaults_to_ALL() throws Exception { + Page page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 50), 0); + when(transferMonitoringService.retrieveSeriesPageable(any(), any())).thenReturn(page); + + mockMvc.perform(get("/api/monitoring/transfers").param("status", "NOT_A_REAL_STATUS")) + .andExpect(status().isOk()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransferStatusFilter.class); + verify(transferMonitoringService).retrieveSeriesPageable(captor.capture(), any()); + org.junit.jupiter.api.Assertions.assertEquals(TransferStatusType.ALL, + captor.getValue().getTransferStatusType()); + } + + @Test + void getTransfers_forwards_uid_filters() throws Exception { + Page page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 50), 0); + when(transferMonitoringService.retrieveSeriesPageable(any(), any())).thenReturn(page); + + mockMvc + .perform(get("/api/monitoring/transfers").param("studyUid", "1.2.840") + .param("serieUid", "1.2.3") + .param("status", "ERROR")) + .andExpect(status().isOk()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransferStatusFilter.class); + verify(transferMonitoringService).retrieveSeriesPageable(captor.capture(), any()); + TransferStatusFilter filter = captor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals("1.2.840", filter.getStudyUid()); + org.junit.jupiter.api.Assertions.assertEquals("1.2.3", filter.getSerieUid()); + org.junit.jupiter.api.Assertions.assertEquals(TransferStatusType.ERROR, filter.getTransferStatusType()); + } + + @Test + void countTransfers_returns_count_json() throws Exception { + when(transferMonitoringService.countSeries(any())).thenReturn(123L); + mockMvc.perform(get("/api/monitoring/transfers/count")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.count").value(123)); + } + + @Test + void exportTransfers_returns_csv_attachment() throws Exception { + byte[] csvBytes = "h1,h2\nv1,v2\n".getBytes(); + when(transferMonitoringService.buildCsv(any(), any())).thenReturn(csvBytes); + + mockMvc.perform(get("/api/monitoring/transfers/export")) + .andExpect(status().isOk()) + .andExpect(result -> org.junit.jupiter.api.Assertions + .assertTrue(result.getResponse().getHeader("Content-Disposition").contains("monitoring-export.csv"))); + } + + @Test + void exportTransfers_returns_500_on_service_failure() throws Exception { + when(transferMonitoringService.buildCsv(any(), any())).thenThrow(new RuntimeException("boom")); + mockMvc.perform(get("/api/monitoring/transfers/export")).andExpect(status().isInternalServerError()); + // Sanity: service did get called + verify(transferMonitoringService, Mockito.atLeastOnce()).buildCsv(any(), any()); + // And we never reached the paginated JSON branch + verify(transferMonitoringService, never()).retrieveSeriesPageable(any(), any()); + } + +} diff --git a/src/test/java/org/karnak/backend/controller/ProfileControllerTest.java b/src/test/java/org/karnak/backend/controller/ProfileControllerTest.java new file mode 100644 index 00000000..8a3b1fca --- /dev/null +++ b/src/test/java/org/karnak/backend/controller/ProfileControllerTest.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2024-2026 Karnak Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.karnak.backend.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.karnak.backend.data.entity.ProfileElementEntity; +import org.karnak.backend.data.entity.ProfileEntity; +import org.karnak.backend.service.profilepipe.ProfilePipeService; +import org.karnak.frontend.profile.component.errorprofile.ProfileError; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class ProfileControllerTest { + + private ProfilePipeService profilePipeService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + profilePipeService = Mockito.mock(ProfilePipeService.class); + mockMvc = MockMvcBuilders.standaloneSetup(new ProfileController(profilePipeService)).build(); + } + + @Test + void getAllProfiles_returns_204_when_empty() throws Exception { + when(profilePipeService.getAllProfiles()).thenReturn(List.of()); + mockMvc.perform(get("/api/profiles")).andExpect(status().isNoContent()); + } + + @Test + void getAllProfiles_returns_summary_with_safe_defaults_for_nulls() throws Exception { + ProfileEntity p = new ProfileEntity(); + p.setName(null); // null fields must be serialized as "" + p.setVersion(null); + p.setMinimumKarnakVersion(null); + p.setByDefault(null); + when(profilePipeService.getAllProfiles()).thenReturn(List.of(p)); + + mockMvc.perform(get("/api/profiles")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("")) + .andExpect(jsonPath("$[0].version").value("")) + .andExpect(jsonPath("$[0].minimumKarnakVersion").value("")) + .andExpect(jsonPath("$[0].byDefault").value(false)); + } + + @Test + void uploadProfile_rejects_empty_file() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "empty.yml", "text/yaml", new byte[0]); + mockMvc.perform(multipart("/api/profiles").file(file)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").exists()); + verify(profilePipeService, never()).saveProfilePipe(any(), anyBoolean()); + } + + @Test + void uploadProfile_rejects_invalid_yaml() throws Exception { + // Tab characters in YAML are syntactically invalid. + String invalidYaml = "\tname: oops\n\tversion: nope\n"; + MockMultipartFile file = new MockMultipartFile("file", "bad.yml", "text/yaml", invalidYaml.getBytes()); + mockMvc.perform(multipart("/api/profiles").file(file)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").exists()); + verify(profilePipeService, never()).saveProfilePipe(any(), anyBoolean()); + } + + @Test + void uploadProfile_returns_422_when_profile_has_validation_errors() throws Exception { + // Build a minimal valid YAML that gets parsed but whose validation reports + // errors. + // NOTE: the top-level key is "profileElements", matching ProfilePipeBody's + // setter. + String yaml = "name: P\n" + "version: \"1.0\"\n" + "minimumKarnakVersion: \"1.0.0\"\n" + "profileElements:\n" + + " - name: \"bad\"\n" + " codename: \"not.a.real.codename\"\n"; + MockMultipartFile file = new MockMultipartFile("file", "p.yml", "text/yaml", yaml.getBytes()); + + ProfileElementEntity element = new ProfileElementEntity(); + element.setName("bad"); + ProfileError err = new ProfileError(element); + err.setError("Cannot find the profile codename: not.a.real.codename"); + when(profilePipeService.validateProfile(any())).thenReturn(List.of(err)); + + mockMvc.perform(multipart("/api/profiles").file(file)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.errors[0]").value("bad: Cannot find the profile codename: not.a.real.codename")); + + verify(profilePipeService, never()).saveProfilePipe(any(), anyBoolean()); + } + + @Test + void getProfile_returns_404_when_missing() throws Exception { + when(profilePipeService.getAllProfiles()).thenReturn(List.of()); + mockMvc.perform(get("/api/profiles/123")).andExpect(status().isNotFound()); + } + + @Test + void updateProfile_returns_404_when_missing() throws Exception { + when(profilePipeService.getAllProfiles()).thenReturn(List.of()); + mockMvc.perform(put("/api/profiles/123").contentType(MediaType.APPLICATION_JSON).content("{\"name\":\"x\"}")) + .andExpect(status().isNotFound()); + } + + @Test + void deleteProfile_returns_204_when_present() throws Exception { + ProfileEntity p = new ProfileEntity(); + p.setId(1L); + when(profilePipeService.getAllProfiles()).thenReturn(List.of(p)); + mockMvc.perform(delete("/api/profiles/1")).andExpect(status().isNoContent()); + verify(profilePipeService).deleteProfile(p); + } + +} diff --git a/src/test/java/org/karnak/backend/controller/ProjectControllerTest.java b/src/test/java/org/karnak/backend/controller/ProjectControllerTest.java new file mode 100644 index 00000000..52402fc6 --- /dev/null +++ b/src/test/java/org/karnak/backend/controller/ProjectControllerTest.java @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2024-2026 Karnak Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.karnak.backend.controller; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.karnak.backend.cache.Patient; +import org.karnak.backend.cache.PatientClient; +import org.karnak.backend.data.entity.ProfileEntity; +import org.karnak.backend.data.entity.ProjectEntity; +import org.karnak.backend.data.entity.SecretEntity; +import org.karnak.backend.service.ProjectService; +import org.karnak.backend.service.profilepipe.ProfilePipeService; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class ProjectControllerTest { + + private ProjectService projectService; + + private ProfilePipeService profilePipeService; + + private PatientClient patientClient; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + projectService = Mockito.mock(ProjectService.class); + profilePipeService = Mockito.mock(ProfilePipeService.class); + patientClient = Mockito.mock(PatientClient.class); + mockMvc = MockMvcBuilders + .standaloneSetup(new ProjectController(projectService, profilePipeService, patientClient)) + .build(); + } + + @Test + void getAllProjects_returns_204_when_empty() throws Exception { + when(projectService.getAllProjects()).thenReturn(List.of()); + mockMvc.perform(get("/api/projects")).andExpect(status().isNoContent()); + } + + @Test + void getAllProjects_does_not_expose_secret_key_bytes() throws Exception { + ProjectEntity project = new ProjectEntity(); + project.setId(1L); + project.setName("p"); + SecretEntity secret = new SecretEntity(new byte[] { 0x01, 0x02, 0x03, 0x04 }); + secret.setId(7L); + secret.setActive(true); + project.addActiveSecretEntity(secret); + when(projectService.getAllProjects()).thenReturn(List.of(project)); + + MvcResult result = mockMvc.perform(get("/api/projects")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("p")) + .andReturn(); + + String body = result.getResponse().getContentAsString(); + assertFalse(body.contains("secretKey"), + "Raw HMAC bytes must never be serialized in the project response: " + body); + } + + @Test + void createProject_rejects_missing_name() throws Exception { + mockMvc.perform(post("/api/projects").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isBadRequest()); + + verify(projectService, never()).save(any()); + } + + @Test + void createProject_returns_400_for_non_numeric_profile_id() throws Exception { + mockMvc + .perform(post("/api/projects").contentType(MediaType.APPLICATION_JSON) + .content("{\"name\": \"p\", \"profileId\": \"not-a-number\"}")) + .andExpect(status().isBadRequest()); + + verify(projectService, never()).save(any()); + } + + @Test + void createProject_returns_404_when_profile_missing() throws Exception { + when(profilePipeService.getAllProfiles()).thenReturn(List.of()); + mockMvc + .perform(post("/api/projects").contentType(MediaType.APPLICATION_JSON) + .content("{\"name\": \"p\", \"profileId\": 1}")) + .andExpect(status().isNotFound()); + + verify(projectService, never()).save(any()); + } + + @Test + void createProject_returns_201_when_no_profile() throws Exception { + when(projectService.save(any(ProjectEntity.class))).thenAnswer(inv -> { + ProjectEntity arg = inv.getArgument(0); + arg.setId(42L); + return arg; + }); + mockMvc.perform(post("/api/projects").contentType(MediaType.APPLICATION_JSON).content("{\"name\": \"p\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(42)); + } + + @Test + void updateProject_can_detach_profile_via_null() throws Exception { + ProjectEntity project = new ProjectEntity(); + project.setId(1L); + project.setName("old"); + ProfileEntity attached = new ProfileEntity(); + attached.setId(9L); + project.setProfileEntity(attached); + when(projectService.retrieveProject(1L)).thenReturn(project); + + mockMvc.perform(put("/api/projects/1").contentType(MediaType.APPLICATION_JSON).content("{\"profileId\": null}")) + .andExpect(status().isOk()); + + // The detach happens in-place on the same object that was returned by + // retrieveProject. + org.junit.jupiter.api.Assertions.assertNull(project.getProfileEntity(), + "profileEntity should have been cleared by the null payload"); + verify(projectService).update(project); + } + + @Test + void addSecret_rejects_invalid_hex_length() throws Exception { + ProjectEntity project = new ProjectEntity(); + project.setId(1L); + when(projectService.retrieveProject(1L)).thenReturn(project); + + mockMvc + .perform(post("/api/projects/1/secrets").contentType(MediaType.APPLICATION_JSON) + .content("{\"hexKey\": \"deadbeef\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").exists()); + + verify(projectService, never()).save(any()); + } + + @Test + void addSecret_rejects_non_hex_characters() throws Exception { + ProjectEntity project = new ProjectEntity(); + project.setId(1L); + when(projectService.retrieveProject(1L)).thenReturn(project); + + // 32 chars but contains 'z' which is not hex + mockMvc + .perform(post("/api/projects/1/secrets").contentType(MediaType.APPLICATION_JSON) + .content("{\"hexKey\": \"zzzzbeefdeadbeefdeadbeefdeadbeef\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void addSecret_generates_random_when_no_body() throws Exception { + ProjectEntity project = new ProjectEntity(); + project.setId(1L); + when(projectService.retrieveProject(1L)).thenReturn(project); + when(projectService.save(any(ProjectEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + + mockMvc.perform(post("/api/projects/1/secrets").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.hexKey").exists()) + .andExpect(jsonPath("$.active").value(true)); + } + + @Test + void addExternalId_rejects_invalid_birth_date() throws Exception { + ProjectEntity project = new ProjectEntity(); + project.setId(1L); + when(projectService.retrieveProject(1L)).thenReturn(project); + + mockMvc + .perform(post("/api/projects/1/external-ids").contentType(MediaType.APPLICATION_JSON) + .content("{\"pseudonym\":\"P\",\"patientId\":\"ID\",\"patientBirthDate\":\"not-a-date\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void addExternalId_returns_404_when_project_missing() throws Exception { + when(projectService.retrieveProject(99L)).thenReturn(null); + + mockMvc + .perform(post("/api/projects/99/external-ids").contentType(MediaType.APPLICATION_JSON) + .content("{\"pseudonym\":\"P\",\"patientId\":\"ID\"}")) + .andExpect(status().isNotFound()); + } + + @Test + void addExternalId_returns_409_when_patient_exists() throws Exception { + ProjectEntity project = new ProjectEntity(); + project.setId(1L); + when(projectService.retrieveProject(1L)).thenReturn(project); + when(patientClient.put(any(), any())).thenReturn(new Patient("OTHER", "ID", "", "", null, "", "")); + + mockMvc + .perform(post("/api/projects/1/external-ids").contentType(MediaType.APPLICATION_JSON) + .content("{\"pseudonym\":\"P\",\"patientId\":\"ID\"}")) + .andExpect(status().isConflict()); + } + + @Test + void importExternalIds_skips_invalid_dates_without_500() throws Exception { + ProjectEntity project = new ProjectEntity(); + project.setId(1L); + when(projectService.retrieveProject(1L)).thenReturn(project); + when(patientClient.put(any(), any())).thenReturn(null); + + mockMvc + .perform(post("/api/projects/1/external-ids/import").contentType(MediaType.APPLICATION_JSON) + .content("[{\"pseudonym\":\"A\",\"patientId\":\"1\"}," + + "{\"pseudonym\":\"B\",\"patientId\":\"2\",\"patientBirthDate\":\"junk\"}]")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.added").value(1)) + .andExpect(jsonPath("$.skipped").value(1)); + } + + @Test + void deleteExternalId_returns_404_when_patient_missing() throws Exception { + ProjectEntity project = new ProjectEntity(); + project.setId(1L); + when(projectService.retrieveProject(1L)).thenReturn(project); + when(patientClient.get(any())).thenReturn(null); + + mockMvc.perform(delete("/api/projects/1/external-ids/MISSING").param("issuerId", "")) + .andExpect(status().isNotFound()); + } + + @Test + void deleteAllExternalIds_returns_404_when_project_missing() throws Exception { + when(projectService.retrieveProject(99L)).thenReturn(null); + mockMvc.perform(delete("/api/projects/99/external-ids")).andExpect(status().isNotFound()); + } + + @Test + void getExternalIds_returns_only_project_scoped_patients() throws Exception { + ProjectEntity project = new ProjectEntity(); + project.setId(1L); + when(projectService.retrieveProject(1L)).thenReturn(project); + + Patient p1 = new Patient("PS1", "ID1", "F", "L", null, "", ""); + p1.setProjectID(1L); + Patient p2 = new Patient("PS2", "ID2", "F", "L", null, "", ""); + p2.setProjectID(2L); // different project + when(patientClient.getAll()).thenReturn(List.of(p1, p2)); + + mockMvc.perform(get("/api/projects/1/external-ids")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].pseudonym").value("PS1")); + } + + @Test + void getExternalIds_returns_empty_list_when_cache_empty() throws Exception { + ProjectEntity project = new ProjectEntity(); + project.setId(1L); + when(projectService.retrieveProject(1L)).thenReturn(project); + when(patientClient.getAll()).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/projects/1/external-ids")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + +} diff --git a/src/test/java/org/karnak/backend/data/entity/ProjectEntityTest.java b/src/test/java/org/karnak/backend/data/entity/ProjectEntityTest.java index e4266d51..ce594077 100644 --- a/src/test/java/org/karnak/backend/data/entity/ProjectEntityTest.java +++ b/src/test/java/org/karnak/backend/data/entity/ProjectEntityTest.java @@ -65,6 +65,19 @@ void retrieve_active_secret_returns_the_active_one_or_null() { assertFalse(inactive.isActive()); } + @Test + void add_active_secret_entity_sets_back_reference_on_secret() { + ProjectEntity entity = new ProjectEntity(); + entity.setId(1L); + SecretEntity secret = new SecretEntity(new byte[16]); + + entity.addActiveSecretEntity(secret); + + // The owning side of the @ManyToOne must point back to the project so + // Hibernate writes a non-null project_id FK on insert. + assertSame(entity, secret.getProjectEntity()); + } + @Test void apply_active_secret_activates_only_the_target() { ProjectEntity entity = new ProjectEntity(); diff --git a/src/test/java/org/karnak/backend/service/ProjectServiceTest.java b/src/test/java/org/karnak/backend/service/ProjectServiceTest.java index 5a6ac396..10df8f8f 100644 --- a/src/test/java/org/karnak/backend/service/ProjectServiceTest.java +++ b/src/test/java/org/karnak/backend/service/ProjectServiceTest.java @@ -127,6 +127,8 @@ void should_call_delete_from_repository() { // Init data ProjectEntity projectEntity = new ProjectEntity(); projectEntity.setId(1L); + Mockito.when(destinationRepositoryMock.findByTagMorphingProjectEntity(projectEntity)) + .thenReturn(Collections.emptyList()); // Call service projectService.remove(projectEntity); @@ -135,6 +137,36 @@ void should_call_delete_from_repository() { Mockito.verify(projectRepositoryMock, Mockito.times(1)).deleteById(Mockito.anyLong()); } + @Test + void should_clear_destination_references_before_deleting_project() { + // A project referenced by one destination for de-identification and by another + // for tag morphing: removing the project must detach both instead of leaving a + // dangling FK that would violate the database constraint on delete. + ProjectEntity projectEntity = new ProjectEntity(); + projectEntity.setId(1L); + + DestinationEntity deidentifyDestination = new DestinationEntity(); + deidentifyDestination.setId(10L); + deidentifyDestination.setDeIdentificationProjectEntity(projectEntity); + projectEntity.setDestinationEntities(List.of(deidentifyDestination)); + + DestinationEntity tagMorphingDestination = new DestinationEntity(); + tagMorphingDestination.setId(20L); + tagMorphingDestination.setTagMorphingProjectEntity(projectEntity); + Mockito.when(destinationRepositoryMock.findByTagMorphingProjectEntity(projectEntity)) + .thenReturn(List.of(tagMorphingDestination)); + + // Call service + projectService.remove(projectEntity); + + // Test results + assertNull(deidentifyDestination.getDeIdentificationProjectEntity()); + assertNull(tagMorphingDestination.getTagMorphingProjectEntity()); + Mockito.verify(destinationRepositoryMock, Mockito.times(1)).save(deidentifyDestination); + Mockito.verify(destinationRepositoryMock, Mockito.times(1)).save(tagMorphingDestination); + Mockito.verify(projectRepositoryMock, Mockito.times(1)).deleteById(1L); + } + @Test void should_retrieve_all_projects() { // Init data From 615fe9bdad9019e928e4df9005f00dc4e50f9b53 Mon Sep 17 00:00:00 2001 From: jbardet Date: Sat, 18 Jul 2026 15:59:59 +0200 Subject: [PATCH 2/3] docs: API.md reference, optional Python client, and setup script API.md documents every endpoint added in the previous commit (updated for the current aggregated per-series monitoring model: status values ALL/SENT/NOT_SENT/EXCLUDED/ERROR, no sopInstanceUid/quoteCharacter). karnak-api-client (python-client/) and setup_deid_gateway.py are an optional companion, not core: a thin authenticated HTTP client plus an idempotent desired-state apply_config() from a JSON document. Kept as a separate commit so it can be split into its own PR if preferred. Ported from jbardet/karnak (1.1.1-based fork) onto current master. --- API.md | 1285 +++++++++++++++++++ python-client/.gitignore | 5 + python-client/README.md | 53 + python-client/karnak_api_client/__init__.py | 38 + python-client/karnak_api_client/apply.py | 367 ++++++ python-client/karnak_api_client/client.py | 450 +++++++ python-client/pyproject.toml | 24 + python-client/tests/test_apply.py | 105 ++ python-client/tests/test_client.py | 252 ++++ setup_deid_gateway.py | 360 ++++++ 10 files changed, 2939 insertions(+) create mode 100644 API.md create mode 100644 python-client/.gitignore create mode 100644 python-client/README.md create mode 100644 python-client/karnak_api_client/__init__.py create mode 100644 python-client/karnak_api_client/apply.py create mode 100644 python-client/karnak_api_client/client.py create mode 100644 python-client/pyproject.toml create mode 100644 python-client/tests/test_apply.py create mode 100644 python-client/tests/test_client.py create mode 100644 setup_deid_gateway.py diff --git a/API.md b/API.md new file mode 100644 index 00000000..57a1fd92 --- /dev/null +++ b/API.md @@ -0,0 +1,1285 @@ +# Karnak REST API Reference + +This document covers every REST API endpoint exposed by Karnak, with full request/response details and `curl` examples. + +**Base URL:** `http://localhost:8081` +**Default credentials:** `admin` / `karnak` +**Authentication:** HTTP Basic or Spring form-login session cookie (all endpoints except `/api/echo/destinations`). +> With the default in-memory IdP this build accepts HTTP Basic on `/api/*`. +> Form-login also works: POST credentials to `/login` once and carry the +> resulting `JSESSIONID` cookie on every subsequent request. The Python +> package in `python-client/` (`karnak-api-client`) handles both via +> `KarnakClient` (auth_mode `auto`/`basic`/`form`). +> +> ``` +> POST /login +> Content-Type: application/x-www-form-urlencoded +> +> username=admin&password=karnak +> ``` +> +> On success Karnak responds `302` to `/`; on failure `302` to `/login?error`. +> The `curl` examples below use `--cookie-jar` / `--cookie` to replicate this: +> +> ```bash +> # Obtain session cookie +> curl -c /tmp/karnak.jar -X POST http://localhost:8081/login \ +> -d "username=admin&password=karnak" +> +> # Use the cookie for API calls +> curl -b /tmp/karnak.jar http://localhost:8081/api/forward-nodes +> ``` +> +> When Karnak is started with `IDP=oidc`, the form-login endpoint uses OIDC +> instead of in-memory credentials and these API calls can only be reached +> through an authenticated OAuth2 browser session. + +--- + +## Table of Contents + +1. [Echo](#1-echo) +2. [Forward Nodes](#2-forward-nodes) +3. [Source Nodes](#3-source-nodes) +4. [Destinations](#4-destinations) +5. [Profiles](#5-profiles) +6. [Projects](#6-projects) +7. [Project Secrets](#7-project-secrets) +8. [External IDs](#8-external-ids) +9. [Auth Configs](#9-auth-configs) +10. [Monitoring](#10-monitoring) +11. [End-to-end example](#11-end-to-end-example) + +--- + +## 1. Echo + +Check the connectivity status of configured destinations for a given source AE Title. **No authentication required.** + +### `GET /api/echo/destinations` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `srcAet` | query | yes | Source AE Title | + +**Response codes:** `200 OK`, `204 No Content` + +**Response body (200):** +```xml + + + https://dicomweb.example.com/studies + 0 + + + ARCHIVE + 0 + + +``` + +> `status: 0` = reachable. Non-zero = unreachable or error. + +```bash +# XML response (default) +curl "http://localhost:8081/api/echo/destinations?srcAet=MY_GATEWAY" + +# JSON response +curl -H "Accept: application/json" \ + "http://localhost:8081/api/echo/destinations?srcAet=MY_GATEWAY" +``` + +--- + +## 2. Forward Nodes + +A **Forward Node** represents a gateway entry point identified by an AE Title. DICOM senders target this AE Title to push images through Karnak. + +### `GET /api/forward-nodes` + +List all forward nodes. + +**Response codes:** `200 OK`, `204 No Content` + +```bash +curl -u admin:karnak http://localhost:8081/api/forward-nodes +``` + +**Response body (200):** +```json +[ + { + "id": 1, + "fwdAeTitle": "MY_GATEWAY", + "fwdDescription": "Main gateway node", + "sourceNodes": [], + "destinationEntities": [] + } +] +``` + +--- + +### `POST /api/forward-nodes` + +Create a new forward node. + +**Request body:** +```json +{ + "fwdAeTitle": "MY_GATEWAY", + "fwdDescription": "Main gateway node" +} +``` + +**Response codes:** `201 Created`, `400 Bad Request` (missing/blank `fwdAeTitle`), `409 Conflict` (AE Title already exists) + +```bash +curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes \ + -H "Content-Type: application/json" \ + -d '{"fwdAeTitle": "MY_GATEWAY", "fwdDescription": "Main gateway node"}' +``` + +--- + +### `GET /api/forward-nodes/{id}` + +Get a forward node by ID. + +**Response codes:** `200 OK`, `404 Not Found` + +```bash +curl -u admin:karnak http://localhost:8081/api/forward-nodes/1 +``` + +--- + +### `PUT /api/forward-nodes/{id}` + +Update a forward node. **Only `fwdAeTitle` and `fwdDescription` are updatable** — +the server preserves existing source nodes and destinations even if they are +absent (or empty) in the request body. To add/remove children, use the +`/source-nodes` and `/destinations` sub-resources. + +**Request body:** +```json +{ + "fwdAeTitle": "MY_GATEWAY", + "fwdDescription": "Updated description" +} +``` + +**Response codes:** `200 OK`, `404 Not Found` + +```bash +curl -u admin:karnak -X PUT http://localhost:8081/api/forward-nodes/1 \ + -H "Content-Type: application/json" \ + -d '{"fwdAeTitle": "MY_GATEWAY", "fwdDescription": "Updated description"}' +``` + +--- + +### `DELETE /api/forward-nodes/{id}` + +Delete a forward node and all its source nodes and destinations. + +**Response codes:** `204 No Content`, `404 Not Found` + +```bash +curl -u admin:karnak -X DELETE http://localhost:8081/api/forward-nodes/1 +``` + +--- + +## 3. Source Nodes + +Source nodes restrict which DICOM senders are accepted by a forward node. If none are configured, all sources are accepted. + +### `GET /api/forward-nodes/{id}/source-nodes` + +List source nodes for a forward node. + +**Response codes:** `200 OK`, `404 Not Found` + +```bash +curl -u admin:karnak http://localhost:8081/api/forward-nodes/1/source-nodes +``` + +**Response body (200):** +```json +[ + { + "id": 5, + "description": "Main PACS", + "aeTitle": "PACS_SRC", + "hostname": "192.168.1.10", + "checkHostname": true + } +] +``` + +--- + +### `POST /api/forward-nodes/{id}/source-nodes` + +Add a source node. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `aeTitle` | string | yes | AE Title (max 16 chars) | +| `hostname` | string | no | IP or hostname | +| `checkHostname` | boolean | no | Enforce hostname check (default: false) | +| `description` | string | no | Free text description | + +**Response codes:** `201 Created`, `404 Not Found` + +```bash +curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/source-nodes \ + -H "Content-Type: application/json" \ + -d '{ + "aeTitle": "PACS_SRC", + "hostname": "192.168.1.10", + "checkHostname": true, + "description": "Main PACS" + }' +``` + +--- + +### `DELETE /api/forward-nodes/{id}/source-nodes/{sourceNodeId}` + +Remove a source node. + +**Response codes:** `204 No Content`, `404 Not Found` + +```bash +curl -u admin:karnak -X DELETE http://localhost:8081/api/forward-nodes/1/source-nodes/5 +``` + +--- + +## 4. Destinations + +A destination is a forwarding target — either a **DICOM node** (`type: dicom`) or a **DICOMWeb STOW-RS endpoint** (`type: stow`). + +### `GET /api/forward-nodes/{id}/destinations` + +List all destinations of a forward node. + +**Response codes:** `200 OK`, `404 Not Found` + +```bash +curl -u admin:karnak http://localhost:8081/api/forward-nodes/1/destinations +``` + +--- + +### `POST /api/forward-nodes/{id}/destinations` + +Add a destination. + +#### DICOM destination + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `destinationType` | string | yes | Must be `"dicom"` | +| `aeTitle` | string | yes | Destination AE Title (max 16 chars) | +| `hostname` | string | yes | Destination host/IP | +| `port` | integer | yes | Port (1–65535) | +| `description` | string | no | Free text description | +| `activate` | boolean | no | Enable/disable (default: true) | +| `condition` | string | no | DICOM expression filter | +| `useaetdest` | boolean | no | Use destination AET as calling AET | +| `desidentification` | boolean | no | Enable de-identification | +| `deIdentificationProject` | object | no | `{"id": N}` — project to use for de-identification | +| `pseudonymType` | string | no | How the external pseudonym is resolved when de-identification is enabled (default: `CACHE_EXTID`). See [Pseudonym type](#pseudonym-type-de-identification) below. | +| `issuerByDefault` | string | no | Default Issuer of Patient ID used with Patient ID for unique identification | +| `tag` | string | no | DICOM tag (8 hex digits, e.g. `00100010`) — required for `EXTID_IN_TAG` | +| `delimiter` | string | no | Delimiter to split tag value — required when `position` > 0 for `EXTID_IN_TAG` | +| `position` | integer | no | Zero-based index after split — required when `delimiter` is set for `EXTID_IN_TAG` | +| `savePseudonym` | boolean | no | Store pseudonym read from DICOM tag in cache (`EXTID_IN_TAG` only) | +| `pseudonymUrl` | string | no | External API URL (DICOM expressions allowed) — required for `EXTID_API` | +| `method` | string | no | `GET` or `POST` — required for `EXTID_API` | +| `responsePath` | string | no | JSON path to pseudonym in API response — required for `EXTID_API` | +| `body` | string | no | JSON request body — required for `EXTID_API` when `method` is `POST` | +| `authConfig` | string | no | Auth config code (from `/api/auth-configs`) for `EXTID_API` | +| `activateTagMorphing` | boolean | no | Enable tag morphing | +| `tagMorphingProject` | object | no | `{"id": N}` — project for tag morphing | +| `activateNotification` | boolean | no | Enable email notifications | +| `notify` | string | no | Comma-separated email list | +| `notifyObjectPattern` | string | no | Email subject pattern | +| `notifyObjectValues` | string | no | DICOM tag values to embed in subject | +| `notifyInterval` | integer | no | Notification delay in seconds | +| `filterBySOPClasses` | boolean | no | Enable SOP class filtering | +| `transferSyntax` | string | no | Transfer syntax UID | +| `transcodeOnlyUncompressed` | boolean | no | Only transcode uncompressed images | + +```bash +curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/destinations \ + -H "Content-Type: application/json" \ + -d '{ + "destinationType": "dicom", + "description": "Long-term archive", + "aeTitle": "ARCHIVE", + "hostname": "192.168.1.20", + "port": 11112, + "activate": true + }' +``` + +#### DICOM destination with de-identification + +```bash +curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/destinations \ + -H "Content-Type: application/json" \ + -d '{ + "destinationType": "dicom", + "aeTitle": "ARCHIVE_ANON", + "hostname": "192.168.1.20", + "port": 11112, + "activate": true, + "desidentification": true, + "deIdentificationProject": {"id": 1}, + "activateNotification": true, + "notify": "admin@hospital.org", + "notifyObjectPattern": "[Karnak] %s %.30s", + "notifyObjectValues": "PatientID,StudyDescription", + "notifyInterval": 45 + }' +``` + +#### Pseudonym type (de-identification) + +When `desidentification` is `true`, `pseudonymType` selects how Karnak obtains the **external pseudonym** before applying the project profile. Use the **enum name** in JSON (not the UI label). + +| Value | Description | Additional fields | +|-------|-------------|-------------------| +| `CACHE_EXTID` | Lookup in the project's external-ID cache (default) | Pre-load mappings via `POST /api/projects/{id}/external-ids` | +| `EXTID_IN_TAG` | Read from a DICOM tag (optional split by delimiter) | `tag`, optional `delimiter` + `position`, optional `savePseudonym` | +| `EXTID_API` | HTTP call to an external service | `pseudonymUrl`, `method`, `responsePath`, optional `body` (POST), optional `authConfig` | + +**DICOM destination — pseudonym from cache (default):** + +```bash +curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/destinations \ + -H "Content-Type: application/json" \ + -d '{ + "destinationType": "dicom", + "aeTitle": "ARCHIVE_ANON", + "hostname": "192.168.1.20", + "port": 11112, + "activate": true, + "desidentification": true, + "deIdentificationProject": {"id": 1}, + "pseudonymType": "CACHE_EXTID" + }' +``` + +**DICOM destination — pseudonym in a DICOM tag:** + +```bash +curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/destinations \ + -H "Content-Type: application/json" \ + -d '{ + "destinationType": "dicom", + "aeTitle": "ARCHIVE_ANON", + "hostname": "192.168.1.20", + "port": 11112, + "activate": true, + "desidentification": true, + "deIdentificationProject": {"id": 1}, + "pseudonymType": "EXTID_IN_TAG", + "tag": "00100010", + "delimiter": "^", + "position": 0 + }' +``` + +**DICOM destination — pseudonym from external API:** + +```bash +curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/destinations \ + -H "Content-Type: application/json" \ + -d '{ + "destinationType": "dicom", + "aeTitle": "ARCHIVE_ANON", + "hostname": "192.168.1.20", + "port": 11112, + "activate": true, + "desidentification": true, + "deIdentificationProject": {"id": 1}, + "pseudonymType": "EXTID_API", + "pseudonymUrl": "https://pseudonym.example.com/lookup?patientId={PatientID}", + "method": "GET", + "responsePath": "$.pseudonym" + }' +``` + +To change `pseudonymType` on an existing destination, `GET` the destination, update the field (and type-specific fields), then `PUT` the full object to `/api/forward-nodes/{id}/destinations/{destinationId}`. + +#### STOW-RS destination + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `destinationType` | string | yes | Must be `"stow"` | +| `url` | string | yes | STOW-RS endpoint URL | +| `description` | string | no | Free text description | +| `headers` | string | no | HTTP headers (max 4096 chars) | +| `activate` | boolean | no | Enable/disable (default: true) | +| `condition` | string | no | DICOM expression filter | +| `authConfig` | string | no | Auth config code (from `/api/auth-configs`) | +| `desidentification` | boolean | no | Enable de-identification | +| `deIdentificationProject` | object | no | `{"id": N}` | +| `pseudonymType` | string | no | Same as DICOM — see [Pseudonym type](#pseudonym-type-de-identification) | +| `issuerByDefault` | string | no | Default Issuer of Patient ID | +| `tag` | string | no | For `EXTID_IN_TAG` | +| `delimiter` | string | no | For `EXTID_IN_TAG` | +| `position` | integer | no | For `EXTID_IN_TAG` | +| `savePseudonym` | boolean | no | For `EXTID_IN_TAG` | +| `pseudonymUrl` | string | no | For `EXTID_API` | +| `method` | string | no | For `EXTID_API` | +| `responsePath` | string | no | For `EXTID_API` | +| `body` | string | no | For `EXTID_API` (POST) | +| `authConfig` | string | no | For `EXTID_API` or STOW OAuth | +| `activateTagMorphing` | boolean | no | Enable tag morphing | +| `tagMorphingProject` | object | no | `{"id": N}` | +| `activateNotification` | boolean | no | Enable email notifications | +| `notify` | string | no | Comma-separated email list | +| `filterBySOPClasses` | boolean | no | Enable SOP class filtering | +| `transferSyntax` | string | no | Transfer syntax UID | + +```bash +curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/destinations \ + -H "Content-Type: application/json" \ + -d '{ + "destinationType": "stow", + "description": "Cloud DICOMWeb endpoint", + "url": "https://dicomweb.example.com/wado/rs/studies", + "headers": "Authorization: Bearer eyJhbGc...", + "activate": true + }' +``` + +#### STOW-RS with OAuth2 auth config + +```bash +curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/destinations \ + -H "Content-Type: application/json" \ + -d '{ + "destinationType": "stow", + "url": "https://dicomweb.example.com/wado/rs/studies", + "activate": true, + "authConfig": "keycloak-prod" + }' +``` + +**Response codes:** `201 Created`, `404 Not Found` + +--- + +### `PUT /api/forward-nodes/{id}/destinations/{destinationId}` + +Update a destination. Send the full destination object. + +**Response codes:** `200 OK`, `404 Not Found` + +```bash +curl -u admin:karnak -X PUT http://localhost:8081/api/forward-nodes/1/destinations/3 \ + -H "Content-Type: application/json" \ + -d '{ + "destinationType": "dicom", + "aeTitle": "ARCHIVE", + "hostname": "192.168.1.20", + "port": 11113, + "activate": false + }' +``` + +--- + +### `DELETE /api/forward-nodes/{id}/destinations/{destinationId}` + +Delete a destination. + +**Response codes:** `204 No Content`, `404 Not Found` + +```bash +curl -u admin:karnak -X DELETE http://localhost:8081/api/forward-nodes/1/destinations/3 +``` + +--- + +## 5. Profiles + +De-identification profiles define which DICOM tags to anonymize and how. They are uploaded as YAML files and then assigned to projects. + +### `GET /api/profiles` + +List all profiles (summary: id, name, version, minimumKarnakVersion, byDefault). + +**Response codes:** `200 OK`, `204 No Content` + +```bash +curl -u admin:karnak http://localhost:8081/api/profiles +``` + +**Response body (200):** +```json +[ + { + "id": 1, + "name": "Profile by Default", + "version": "1.0", + "minimumKarnakVersion": "0.9.7", + "byDefault": true + }, + { + "id": 3, + "name": "My Custom Profile", + "version": "2.1", + "minimumKarnakVersion": "1.0.0", + "byDefault": false + } +] +``` + +--- + +### `POST /api/profiles` + +Upload a YAML profile file. The file is validated before saving. + +**Content-Type:** `multipart/form-data` +**Form field:** `file` — the YAML file + +**Response codes:** +- `201 Created` — profile saved +- `400 Bad Request` — unreadable file or invalid YAML syntax +- `422 Unprocessable Entity` — YAML parsed but profile element validation failed (body contains error list) + +```bash +curl -u admin:karnak -X POST http://localhost:8081/api/profiles \ + -F "file=@my-deidentification-profile.yml" +``` + +**Response body (201):** +```json +{"id": 3, "name": "My Custom Profile"} +``` + +**Response body (422):** +```json +{ + "errors": [ + "Tag replacement: Cannot find the profile codename: action.on.specific.tags" + ] +} +``` + +**Example YAML profile structure:** +```yaml +name: My Custom Profile +version: "1.0" +minimumKarnakVersion: "1.0.0" +profileElements: + - name: "Remove patient name" + codename: "action.on.specific.tags" + action: "X" + tags: + - "(0010,0010)" + - name: "Keep study date" + codename: "action.on.specific.tags" + action: "K" + tags: + - "(0008,0020)" +``` + +--- + +### `GET /api/profiles/{id}` + +Get full profile details as JSON. + +**Response codes:** `200 OK`, `404 Not Found` + +```bash +curl -u admin:karnak http://localhost:8081/api/profiles/3 +``` + +--- + +### `GET /api/profiles/{id}/download` + +Download the profile as a YAML file (suitable for re-upload). + +**Response codes:** `200 OK`, `404 Not Found` + +```bash +# Save to file +curl -u admin:karnak http://localhost:8081/api/profiles/3/download -o my-profile.yml + +# Print to stdout +curl -u admin:karnak http://localhost:8081/api/profiles/3/download +``` + +--- + +### `PUT /api/profiles/{id}` + +Update profile metadata only (does not affect profile elements/masks). + +**Request body (all fields optional):** +```json +{ + "name": "Renamed Profile", + "version": "2.0", + "minimumKarnakVersion": "1.1.0" +} +``` + +**Response codes:** `200 OK`, `404 Not Found` + +```bash +curl -u admin:karnak -X PUT http://localhost:8081/api/profiles/3 \ + -H "Content-Type: application/json" \ + -d '{"name": "Renamed Profile", "version": "2.0"}' +``` + +--- + +### `DELETE /api/profiles/{id}` + +Delete a profile. Will fail at DB level if the profile is still referenced by a project. + +**Response codes:** `204 No Content`, `404 Not Found` + +```bash +curl -u admin:karnak -X DELETE http://localhost:8081/api/profiles/3 +``` + +--- + +## 6. Projects + +Projects group a de-identification profile with an HMAC secret key. They are assigned to destinations to enable de-identification or tag morphing. + +### `GET /api/projects` + +List all projects. + +**Response codes:** `200 OK`, `204 No Content` + +```bash +curl -u admin:karnak http://localhost:8081/api/projects +``` + +**Response body (200):** +```json +[ + { + "id": 1, + "name": "Study Group A", + "profileEntity": { + "name": "My Custom Profile", + "version": "1.0" + }, + "secretEntities": [ + { + "id": 2, + "creationDate": "2024-06-01T10:30:00", + "active": true + } + ] + } +] +``` + +> The raw HMAC key bytes are **never** included in this response. Capture the +> key once at creation time from `POST /api/projects/{id}/secrets`. + +--- + +### `POST /api/projects` + +Create a new project. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | yes | Project name | +| `profileId` | long | no | ID of a profile to associate | + +**Response codes:** `201 Created`, `400 Bad Request` (missing name), `404 Not Found` (profile not found) + +```bash +# Project without a profile +curl -u admin:karnak -X POST http://localhost:8081/api/projects \ + -H "Content-Type: application/json" \ + -d '{"name": "Study Group A"}' + +# Project with a profile +curl -u admin:karnak -X POST http://localhost:8081/api/projects \ + -H "Content-Type: application/json" \ + -d '{"name": "Study Group A", "profileId": 3}' +``` + +--- + +### `GET /api/projects/{id}` + +Get a project by ID. + +**Response codes:** `200 OK`, `404 Not Found` + +```bash +curl -u admin:karnak http://localhost:8081/api/projects/1 +``` + +--- + +### `PUT /api/projects/{id}` + +Update a project. All fields optional; only provided fields are changed. + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | New name | +| `profileId` | long or null | New profile ID; send `null` to detach the profile | + +**Response codes:** `200 OK`, `404 Not Found` + +```bash +# Rename and change profile +curl -u admin:karnak -X PUT http://localhost:8081/api/projects/1 \ + -H "Content-Type: application/json" \ + -d '{"name": "Study Group A v2", "profileId": 5}' + +# Detach profile +curl -u admin:karnak -X PUT http://localhost:8081/api/projects/1 \ + -H "Content-Type: application/json" \ + -d '{"profileId": null}' +``` + +--- + +### `DELETE /api/projects/{id}` + +Delete a project. + +**Response codes:** `204 No Content`, `404 Not Found` + +```bash +curl -u admin:karnak -X DELETE http://localhost:8081/api/projects/1 +``` + +--- + +## 7. Project Secrets + +Each project has an HMAC-SHA256 secret key used to pseudonymize patient +identifiers. Only one secret is active at a time; adding a new one deactivates +the previous. + +> **The full hex key is only returned in the `POST /secrets` response.** It is +> never echoed back from `GET /api/projects/...`. Store the value safely on +> create — there is no read-back endpoint. + +### `POST /api/projects/{id}/secrets` + +Add or generate an HMAC secret for the project. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `hexKey` | string | no | Exactly 32 hex characters (16 bytes). Omit for auto-generation. Dashes are stripped automatically; any other separators or non-hex characters are rejected with `400`. | + +**Response codes:** `201 Created`, `400 Bad Request` (invalid hex / wrong length), `404 Not Found` + +```bash +# Auto-generate a random key +curl -u admin:karnak -X POST http://localhost:8081/api/projects/1/secrets \ + -H "Content-Type: application/json" -d '{}' + +# Import a known key +curl -u admin:karnak -X POST http://localhost:8081/api/projects/1/secrets \ + -H "Content-Type: application/json" \ + -d '{"hexKey": "deadbeefdeadbeefdeadbeefdeadbeef"}' + +# Import a key in display format (dashes stripped automatically) +curl -u admin:karnak -X POST http://localhost:8081/api/projects/1/secrets \ + -H "Content-Type: application/json" \ + -d '{"hexKey": "deadbeef-dead-beef-dead-beefdeadbeef"}' +``` + +**Response body (201):** +```json +{ + "projectId": 1, + "hexKey": "deadbeefdeadbeefdeadbeefdeadbeef", + "displayKey": "deadbeef-dead-beef-dead-beefdeadbeef", + "active": true +} +``` + +--- + +## 8. External IDs + +The External ID cache maps real patient identifiers to pseudonyms, enabling lookup-based de-identification. **Data is in-memory — it is lost on application restart.** It is scoped per project. + +### `GET /api/projects/{id}/external-ids` + +List all patient mappings for a project. + +**Response codes:** `200 OK`, `404 Not Found` + +```bash +curl -u admin:karnak http://localhost:8081/api/projects/1/external-ids +``` + +**Response body (200):** +```json +[ + { + "pseudonym": "PSEUDO-001", + "patientId": "PAT-12345", + "patientFirstName": "John", + "patientLastName": "Doe", + "patientName": "Doe^John", + "patientBirthDate": "1980-05-15", + "patientSex": "M", + "issuerOfPatientId": "HospitalA", + "projectID": 1 + } +] +``` + +--- + +### `POST /api/projects/{id}/external-ids` + +Add a single patient mapping. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `pseudonym` | string | yes | Anonymized identifier | +| `patientId` | string | yes | Real patient ID | +| `patientFirstName` | string | no | First name | +| `patientLastName` | string | no | Last name | +| `issuerOfPatientId` | string | no | Issuer of patient ID (used as part of cache key) | +| `patientBirthDate` | string | no | Format: `YYYY-MM-DD` | +| `patientSex` | string | no | `M`, `F`, or `O` | + +**Response codes:** `201 Created`, `400 Bad Request` (missing pseudonym/patientId or malformed `patientBirthDate`), `404 Not Found`, `409 Conflict` (patient already exists) + +```bash +curl -u admin:karnak -X POST http://localhost:8081/api/projects/1/external-ids \ + -H "Content-Type: application/json" \ + -d '{ + "pseudonym": "PSEUDO-001", + "patientId": "PAT-12345", + "patientFirstName": "John", + "patientLastName": "Doe", + "issuerOfPatientId": "HospitalA", + "patientBirthDate": "1980-05-15", + "patientSex": "M" + }' +``` + +--- + +### `POST /api/projects/{id}/external-ids/import` + +Bulk import patient mappings from a JSON array. Duplicate entries (same `patientId` + `issuerOfPatientId`) are skipped. + +**Response codes:** `200 OK`, `404 Not Found` + +```bash +curl -u admin:karnak -X POST http://localhost:8081/api/projects/1/external-ids/import \ + -H "Content-Type: application/json" \ + -d '[ + { + "pseudonym": "P001", + "patientId": "ID001", + "patientFirstName": "Alice", + "patientLastName": "Smith", + "issuerOfPatientId": "HospitalA" + }, + { + "pseudonym": "P002", + "patientId": "ID002", + "patientFirstName": "Bob", + "patientLastName": "Jones", + "issuerOfPatientId": "HospitalA" + } + ]' +``` + +**Response body (200):** +```json +{"added": 2, "skipped": 0} +``` + +--- + +### `PUT /api/projects/{id}/external-ids/{patientId}` + +Update an existing patient mapping. The original record is identified by `{patientId}` (path) and `issuerId` (query param). Only fields present in the body are updated. + +| Query param | Default | Description | +|-------------|---------|-------------| +| `issuerId` | `""` | Issuer of patient ID (part of the cache lookup key) | + +**Response codes:** `200 OK`, `400 Bad Request` (malformed `patientBirthDate`), `404 Not Found` + +```bash +curl -u admin:karnak \ + -X PUT "http://localhost:8081/api/projects/1/external-ids/PAT-12345?issuerId=HospitalA" \ + -H "Content-Type: application/json" \ + -d '{"patientFirstName": "Jonathan", "patientSex": "M"}' +``` + +--- + +### `DELETE /api/projects/{id}/external-ids/{patientId}` + +Delete a single patient mapping. + +| Query param | Default | Description | +|-------------|---------|-------------| +| `issuerId` | `""` | Issuer of patient ID | + +**Response codes:** `204 No Content`, `404 Not Found` + +```bash +curl -u admin:karnak \ + -X DELETE "http://localhost:8081/api/projects/1/external-ids/PAT-12345?issuerId=HospitalA" +``` + +--- + +### `DELETE /api/projects/{id}/external-ids` + +Delete **all** patient mappings for a project. + +**Response codes:** `204 No Content`, `404 Not Found` + +```bash +curl -u admin:karnak -X DELETE http://localhost:8081/api/projects/1/external-ids +``` + +--- + +## 9. Auth Configs + +OAuth2 authentication configurations are used by STOW-RS destinations to obtain bearer tokens automatically. Sensitive fields (`clientId`, `clientSecret`, `accessTokenUrl`, `scope`) are encrypted at rest in the database. + +### `GET /api/auth-configs` + +List all auth configurations. + +**Response codes:** `200 OK`, `204 No Content` + +```bash +curl -u admin:karnak http://localhost:8081/api/auth-configs +``` + +**Response body (200):** +```json +[ + { + "code": "keycloak-prod", + "clientId": "karnak-client", + "accessTokenUrl": "https://auth.example.com/realms/hospital/protocol/openid-connect/token", + "scope": "openid", + "authConfigType": "OAUTH2" + } +] +``` + +> `clientSecret` is **never** returned in responses (write-only). It can only be +> set via `POST` or `PUT` and is encrypted at rest. There is no read-back of an +> existing secret. + +--- + +### `POST /api/auth-configs` + +Create a new auth configuration. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `code` | string | yes | Unique identifier (referenced in destinations) | +| `clientId` | string | no | OAuth2 client ID | +| `clientSecret` | string | no | OAuth2 client secret (write-only; never returned by GET) | +| `accessTokenUrl` | string | no | Token endpoint URL | +| `scope` | string | no | OAuth2 scope | +| `authConfigType` | string | no | Auth type — only `"OAUTH2"` supported (default) | + +**Response codes:** `201 Created`, `400 Bad Request` (missing `code`), `409 Conflict` (code already exists) + +```bash +curl -u admin:karnak -X POST http://localhost:8081/api/auth-configs \ + -H "Content-Type: application/json" \ + -d '{ + "code": "keycloak-prod", + "clientId": "karnak-client", + "clientSecret": "s3cr3t-client-p@ssword", + "accessTokenUrl": "https://auth.example.com/realms/hospital/protocol/openid-connect/token", + "scope": "openid" + }' +``` + +--- + +### `GET /api/auth-configs/{code}` + +Get an auth config by its code identifier. + +**Response codes:** `200 OK`, `404 Not Found` + +```bash +curl -u admin:karnak http://localhost:8081/api/auth-configs/keycloak-prod +``` + +--- + +### `PUT /api/auth-configs/{code}` + +Update an existing auth config. Only provided fields are changed. + +**Response codes:** `200 OK`, `404 Not Found` + +```bash +curl -u admin:karnak -X PUT http://localhost:8081/api/auth-configs/keycloak-prod \ + -H "Content-Type: application/json" \ + -d '{ + "clientSecret": "new-s3cr3t", + "scope": "openid profile" + }' +``` + +--- + +### `DELETE /api/auth-configs/{code}` + +Delete an auth config. + +**Response codes:** `204 No Content`, `404 Not Found` + +```bash +curl -u admin:karnak -X DELETE http://localhost:8081/api/auth-configs/keycloak-prod +``` + +--- + +## 10. Monitoring + +Query, count, and export aggregated DICOM transfer status records logged by Karnak. Each +record is one row per (forward node, destination, series) — counters (`instances`, +`retries`, `sent`, `errors`, `excluded`) accumulate as instances are transferred, rather +than one row per individual transfer event. + +### `GET /api/monitoring/transfers` + +Query transfer records with optional filters and pagination. + +| Query param | Type | Default | Description | +|-------------|------|---------|-------------| +| `studyUid` | string | — | Filter by Study Instance UID (partial match) | +| `serieUid` | string | — | Filter by Series Instance UID (partial match) | +| `status` | string | `ALL` | One of: `ALL`, `SENT`, `NOT_SENT`, `EXCLUDED`, `ERROR` | +| `start` | ISO datetime | — | From date-time (e.g. `2024-01-01T00:00:00`), applies to the series' last activity | +| `end` | ISO datetime | — | To date-time (e.g. `2024-12-31T23:59:59`), applies to the series' last activity | +| `page` | integer | `0` | Page number (0-based). Negative values are clamped to `0`. | +| `size` | integer | `50` | Records per page (1–1000). Out-of-range values are clamped. | + +**Response codes:** `200 OK` + +```bash +# All records, page 0 +curl -u admin:karnak http://localhost:8081/api/monitoring/transfers + +# Filter by status +curl -u admin:karnak \ + "http://localhost:8081/api/monitoring/transfers?status=ERROR" + +# Filter by date range +curl -u admin:karnak \ + "http://localhost:8081/api/monitoring/transfers?start=2024-06-01T00:00:00&end=2024-06-30T23:59:59&size=100" + +# Filter by Study UID +curl -u admin:karnak \ + "http://localhost:8081/api/monitoring/transfers?studyUid=1.2.840.10008" +``` + +**Response body (200):** +```json +{ + "content": [ + { + "forwardNodeId": 1, + "destinationId": 3, + "studyUidOriginal": "1.2.840.10008.5.1.4.1.1.4", + "serieUidOriginal": "...", + "instances": 42, + "retries": 0, + "sent": 42, + "errors": 0, + "excluded": 0, + "firstSeen": "2024-06-15T14:30:00", + "lastSeen": "2024-06-15T14:32:00" + } + ], + "totalElements": 1543, + "totalPages": 31, + "page": 0, + "size": 50 +} +``` + +--- + +### `GET /api/monitoring/transfers/count` + +Count transfer records matching the filter. Accepts the same filter params as the query endpoint (except `page` and `size`). + +**Response codes:** `200 OK` + +```bash +# Count all errors +curl -u admin:karnak \ + "http://localhost:8081/api/monitoring/transfers/count?status=ERROR" +``` + +**Response body (200):** +```json +{"count": 42} +``` + +--- + +### `GET /api/monitoring/transfers/export` + +Download transfer records as a CSV file. Accepts the same filter params as the query endpoint, plus: + +| Query param | Default | Description | +|-------------|---------|-------------| +| `delimiter` | `,` | CSV column separator character | + +**Response codes:** `200 OK`, `500 Internal Server Error` + +```bash +# Export all records as CSV +curl -u admin:karnak \ + "http://localhost:8081/api/monitoring/transfers/export" \ + -o monitoring.csv + +# Export errors only, semicolon delimiter +curl -u admin:karnak \ + "http://localhost:8081/api/monitoring/transfers/export?status=ERROR&delimiter=;" \ + -o errors.csv + +# Export a specific date range +curl -u admin:karnak \ + "http://localhost:8081/api/monitoring/transfers/export?start=2024-06-01T00:00:00&end=2024-06-30T23:59:59" \ + -o june-2024.csv +``` + +--- + +## 11. End-to-end example + +This script sets up a complete Karnak configuration programmatically — no UI needed. + +```bash +#!/bin/bash +BASE="http://localhost:8081" +CREDS="admin:karnak" + +echo "=== 1. Upload a de-identification profile ===" +PROFILE_ID=$(curl -s -u $CREDS -X POST $BASE/api/profiles \ + -F "file=@deidentification.yml" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +echo "Profile ID: $PROFILE_ID" + +echo "=== 2. Create a project and link the profile ===" +PROJECT=$(curl -s -u $CREDS -X POST $BASE/api/projects \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"Clinical Trial 2024\", \"profileId\": $PROFILE_ID}") +PROJECT_ID=$(echo $PROJECT | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +echo "Project ID: $PROJECT_ID" + +echo "=== 3. Generate an HMAC secret for the project ===" +SECRET=$(curl -s -u $CREDS -X POST $BASE/api/projects/$PROJECT_ID/secrets \ + -H "Content-Type: application/json" -d '{}') +echo "Secret: $(echo $SECRET | python3 -c "import sys,json; print(json.load(sys.stdin)['displayKey'])")" + +echo "=== 4. Create an OAuth2 auth config for the STOW destination ===" +curl -s -u $CREDS -X POST $BASE/api/auth-configs \ + -H "Content-Type: application/json" \ + -d '{ + "code": "keycloak-prod", + "clientId": "karnak-client", + "clientSecret": "s3cr3t", + "accessTokenUrl": "https://auth.example.com/token", + "scope": "openid" + }' > /dev/null + +echo "=== 5. Create a forward node (gateway entry point) ===" +NODE=$(curl -s -u $CREDS -X POST $BASE/api/forward-nodes \ + -H "Content-Type: application/json" \ + -d '{"fwdAeTitle": "GATEWAY1", "fwdDescription": "Clinical trial gateway"}') +NODE_ID=$(echo $NODE | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +echo "Forward node ID: $NODE_ID" + +echo "=== 6. Restrict accepted sources (optional) ===" +curl -s -u $CREDS -X POST $BASE/api/forward-nodes/$NODE_ID/source-nodes \ + -H "Content-Type: application/json" \ + -d '{ + "aeTitle": "MODALITY1", + "hostname": "10.0.0.5", + "checkHostname": true, + "description": "MRI scanner" + }' > /dev/null + +echo "=== 7. Add a DICOM destination with de-identification ===" +curl -s -u $CREDS -X POST $BASE/api/forward-nodes/$NODE_ID/destinations \ + -H "Content-Type: application/json" \ + -d "{ + \"destinationType\": \"dicom\", + \"description\": \"Anonymized archive\", + \"aeTitle\": \"PACS_ANON\", + \"hostname\": \"192.168.1.50\", + \"port\": 11112, + \"activate\": true, + \"desidentification\": true, + \"deIdentificationProject\": {\"id\": $PROJECT_ID}, + \"pseudonymType\": \"CACHE_EXTID\" + }" > /dev/null + +echo "=== 8. Add a STOW-RS destination with OAuth2 ===" +curl -s -u $CREDS -X POST $BASE/api/forward-nodes/$NODE_ID/destinations \ + -H "Content-Type: application/json" \ + -d '{ + "destinationType": "stow", + "description": "Cloud DICOMWeb archive", + "url": "https://dicomweb.example.com/wado/rs/studies", + "activate": true, + "authConfig": "keycloak-prod" + }' > /dev/null + +echo "=== 9. Pre-load external ID mappings ===" +curl -s -u $CREDS -X POST $BASE/api/projects/$PROJECT_ID/external-ids/import \ + -H "Content-Type: application/json" \ + -d '[ + {"pseudonym":"P001","patientId":"ID001","patientFirstName":"Alice","patientLastName":"Smith","issuerOfPatientId":"HospitalA"}, + {"pseudonym":"P002","patientId":"ID002","patientFirstName":"Bob","patientLastName":"Jones","issuerOfPatientId":"HospitalA"} + ]' | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Imported: {d[\"added\"]} added, {d[\"skipped\"]} skipped')" + +echo "=== 10. Check echo status ===" +curl -s "http://localhost:8081/api/echo/destinations?srcAet=GATEWAY1" + +echo "" +echo "=== Setup complete ===" +echo "Forward node ID : $NODE_ID (AET: GATEWAY1)" +echo "Project ID : $PROJECT_ID" +echo "Profile ID : $PROFILE_ID" +``` diff --git a/python-client/.gitignore b/python-client/.gitignore new file mode 100644 index 00000000..f0ec10c9 --- /dev/null +++ b/python-client/.gitignore @@ -0,0 +1,5 @@ +*.egg-info/ +__pycache__/ +dist/ +build/ +.pytest_cache/ diff --git a/python-client/README.md b/python-client/README.md new file mode 100644 index 00000000..1aabaf74 --- /dev/null +++ b/python-client/README.md @@ -0,0 +1,53 @@ +# karnak-api-client + +Python client for the REST API exposed by this Karnak fork +([API.md](../API.md) documents every endpoint). + +Two layers: + +- `karnak_api_client.client` — `KarnakClient`: thin authenticated transport + mirroring the REST resources. Supports three auth modes: + - `basic` — HTTP Basic on every request (default in-memory IdP enables it); + - `form` — Spring form-login: POST `/login`, reuse the `JSESSIONID` cookie; + - `auto` (default) — try Basic, fall back to form-login transparently when + the server redirects to `/login` or returns 401. +- `karnak_api_client.apply` — `apply_config(client, config)`: idempotent + desired-state application (profile, project + HMAC secret, auth configs, + forward node, source nodes, destinations, external-ID import) from a JSON + config document. + +## Install + +```sh +pip install "karnak-api-client @ git+https://github.com/jbardet/karnak.git@client-v0.1.0#subdirectory=python-client" +``` + +## Usage + +```python +from karnak_api_client import KarnakClient, KarnakClientConfig + +client = KarnakClient(KarnakClientConfig( + base_url="http://localhost:8081", username="admin", password="karnak", +)) +profiles = client.list_profiles() +``` + +Or configure from the environment (`KARNAK_API_BASE`, `KARNAK_USERNAME`, +`KARNAK_PASSWORD`, `KARNAK_AUTH_MODE`, `KARNAK_API_TIMEOUT_SEC`): + +```python +client = KarnakClient() # KarnakClientConfig.from_env() +``` + +## Versioning + +Tag scheme `client-v` on this repo. The Docker image of the fork uses +`-api.` tags; the two are released independently but the +client tracks the controllers in `src/main/java/org/karnak/backend/controller/`. + +## Consumers + +- MiCo-BID-pipeline (DAG step `00_apply_karnak_config.py`, `micobid` CLI) +- TMLCTP equivalence harness (`integration_tests/karnak_api/`) +- `setup_deid_gateway.py` in this repo diff --git a/python-client/karnak_api_client/__init__.py b/python-client/karnak_api_client/__init__.py new file mode 100644 index 00000000..c5103e3b --- /dev/null +++ b/python-client/karnak_api_client/__init__.py @@ -0,0 +1,38 @@ +"""Python client for the Karnak REST API (karnak-endpoints fork). + +Transport client (``client``) plus idempotent desired-state apply +(``apply``). See API.md at the repository root for endpoint details. +""" + +from .apply import ( + KarnakConfigError, + apply_config, + load_apply_config, + profile_meta_from_yaml, + write_json_atomic, +) +from .client import ( + ApiError, + AuthenticationError, + KarnakApiError, + KarnakClient, + KarnakClientConfig, + KarnakError, +) + +__version__ = "0.1.0" + +__all__ = [ + "ApiError", + "AuthenticationError", + "KarnakApiError", + "KarnakClient", + "KarnakClientConfig", + "KarnakConfigError", + "KarnakError", + "apply_config", + "load_apply_config", + "profile_meta_from_yaml", + "write_json_atomic", + "__version__", +] diff --git a/python-client/karnak_api_client/apply.py b/python-client/karnak_api_client/apply.py new file mode 100644 index 00000000..a4c3c1d4 --- /dev/null +++ b/python-client/karnak_api_client/apply.py @@ -0,0 +1,367 @@ +"""Idempotent desired-state application for Karnak REST resources. + +``apply_config(client, config)`` takes a JSON-compatible config document +describing the desired profile, project (+ HMAC secret), auth configs, +forward node, source nodes, destinations and external-ID imports, and makes +the minimum API calls needed to converge Karnak onto that state. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from .client import KarnakClient + + +class KarnakConfigError(ValueError): + """Raised when a desired Karnak config cannot be built or validated.""" + + +def profile_meta_from_yaml(path: Path) -> dict[str, str]: + """Extract simple top-level YAML name/version without adding PyYAML.""" + meta: dict[str, str] = {} + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + raise KarnakConfigError(f"Cannot read profile YAML {path}: {exc}") from exc + + for line in lines: + match = re.match(r"^(name|version):\s*[\"']?([^\"'#]+)", line) + if match: + meta[match.group(1)] = match.group(2).strip() + if "name" in meta and "version" in meta: + break + return meta + + +def load_apply_config(path: Path) -> dict[str, Any]: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise KarnakConfigError(f"Cannot read JSON config {path}: {exc}") from exc + if not isinstance(raw, dict): + raise KarnakConfigError("Karnak config must be a JSON object") + return raw + + +def write_json_atomic(path: Path, body: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(json.dumps(body, indent=2, sort_keys=True) + "\n", encoding="utf-8") + tmp.replace(path) + + +def _require_object(config: dict[str, Any], key: str) -> dict[str, Any]: + value = config.get(key) + if not isinstance(value, dict): + raise KarnakConfigError(f"config.{key} is required") + return value + + +def _require_list(config: dict[str, Any], key: str) -> list[Any]: + value = config.get(key) or [] + if not isinstance(value, list): + raise KarnakConfigError(f"{key} must be a list") + return value + + +def _load_external_ids(config: dict[str, Any]) -> list[dict[str, Any]]: + rows = _require_list(config, "externalIds") + out: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + raise KarnakConfigError("externalIds entries must be objects") + out.append(row) + + external_ids_path = config.get("externalIdsPath") + if external_ids_path: + p = Path(str(external_ids_path)) + try: + loaded = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise KarnakConfigError(f"Cannot read externalIdsPath {p}: {exc}") from exc + if not isinstance(loaded, list): + raise KarnakConfigError("externalIdsPath must contain a JSON array") + for row in loaded: + if not isinstance(row, dict): + raise KarnakConfigError("externalIdsPath entries must be objects") + out.append(row) + return out + + +def _find_profile( + client: KarnakClient, + *, + name: str, + version: str | None, +) -> dict[str, Any] | None: + for item in client.list_profiles(): + if str(item.get("name", "")) != name: + continue + if version is not None and str(item.get("version", "")) != version: + continue + return item + return None + + +def _ensure_profile(client: KarnakClient, config: dict[str, Any]) -> dict[str, Any]: + profile_cfg = _require_object(config, "profile") + path_raw = profile_cfg.get("path") + if not path_raw: + raise KarnakConfigError("config.profile.path is required") + path = Path(str(path_raw)) + if not path.is_file(): + raise KarnakConfigError(f"Profile YAML does not exist: {path}") + + yaml_meta = profile_meta_from_yaml(path) + name = str(profile_cfg.get("name") or yaml_meta.get("name") or "").strip() + version = str(profile_cfg.get("version") or yaml_meta.get("version") or "").strip() or None + if not name: + raise KarnakConfigError("config.profile.name is required when YAML name cannot be inferred") + + force_upload = bool(profile_cfg.get("forceUpload", False)) + if not force_upload: + existing = _find_profile(client, name=name, version=version) + if existing: + return { + "id": int(existing["id"]), + "name": existing.get("name", name), + "version": existing.get("version", version), + "created": False, + } + + created = client.upload_profile(path) + return { + "id": int(created["id"]), + "name": created.get("name", name), + "version": version, + "created": True, + } + + +def _find_project(client: KarnakClient, name: str) -> dict[str, Any] | None: + for item in client.list_projects(): + if str(item.get("name", "")) == name: + return item + return None + + +def _project_has_active_secret(project: dict[str, Any]) -> bool: + secrets = project.get("secretEntities") or [] + if not isinstance(secrets, list): + return False + return any(bool(s.get("active")) for s in secrets if isinstance(s, dict)) + + +def _ensure_project( + client: KarnakClient, + config: dict[str, Any], + profile_id: int, +) -> dict[str, Any]: + project_cfg = _require_object(config, "project") + name = str(project_cfg.get("name", "")).strip() + if not name: + raise KarnakConfigError("config.project.name is required") + + existing = _find_project(client, name) + if existing: + project_id = int(existing["id"]) + # Skip PUT if the existing project's linked profile already matches by name. + # GET /api/projects responses don't include profileEntity.id, so compare + # by name+version which is what _ensure_profile guarantees stable on. + pe = existing.get("profileEntity") or {} + profile_cfg = _require_object(config, "profile") + desired_name = str(profile_cfg.get("name", "")).strip() + desired_version = str(profile_cfg.get("version", "")).strip() + already_matches = ( + str(pe.get("name", "")).strip() == desired_name + and str(pe.get("version", "")).strip() == desired_version + ) + if not already_matches: + client.update_project(project_id, profile_id=profile_id) + created = False + else: + project = client.create_project(name, profile_id=profile_id) + project_id = int(project["id"]) + created = True + + project = client.get_project(project_id) + secret_created = False + secret_result: dict[str, Any] | None = None + ensure_secret = bool(project_cfg.get("ensureSecret", True)) + secret_hex = project_cfg.get("secretHex") + if secret_hex or (ensure_secret and not _project_has_active_secret(project)): + secret_result = client.add_project_secret(project_id, str(secret_hex) if secret_hex else None) + secret_created = True + + summary: dict[str, Any] = { + "id": project_id, + "name": name, + "created": created, + "secret_created": secret_created, + } + if secret_result: + # Karnak only returns the raw key once. Store it in the apply summary + # for the operator, but callers must not write it to logs. + summary["secret"] = secret_result + return summary + + +def _ensure_auth_configs(client: KarnakClient, config: dict[str, Any]) -> list[dict[str, Any]]: + requested = _require_list(config, "authConfigs") + existing_codes = { + str(item.get("code")) + for item in client.list_auth_configs() + if isinstance(item, dict) and item.get("code") + } + summary: list[dict[str, Any]] = [] + for body in requested: + if not isinstance(body, dict): + raise KarnakConfigError("authConfigs entries must be objects") + code = str(body.get("code", "")).strip() + if not code: + raise KarnakConfigError("authConfigs[].code is required") + if code in existing_codes: + client.update_auth_config(code, body) + summary.append({"code": code, "created": False}) + else: + client.create_auth_config(body) + existing_codes.add(code) + summary.append({"code": code, "created": True}) + return summary + + +def _find_forward_node(client: KarnakClient, ae_title: str) -> dict[str, Any] | None: + for item in client.list_forward_nodes(): + if str(item.get("fwdAeTitle", "")) == ae_title: + return item + return None + + +def _ensure_forward_node(client: KarnakClient, config: dict[str, Any]) -> dict[str, Any]: + node_cfg = _require_object(config, "forwardNode") + ae_title = str(node_cfg.get("aeTitle") or node_cfg.get("fwdAeTitle") or "").strip() + if not ae_title: + raise KarnakConfigError("config.forwardNode.aeTitle is required") + description = str(node_cfg.get("description") or node_cfg.get("fwdDescription") or "") + + existing = _find_forward_node(client, ae_title) + if existing: + node = client.update_forward_node(int(existing["id"]), ae_title, description) + created = False + else: + node = client.create_forward_node(ae_title, description) + created = True + return { + "id": int(node["id"]), + "ae_title": ae_title, + "created": created, + } + + +def _source_key(source: dict[str, Any]) -> tuple[str, str, bool]: + return ( + str(source.get("aeTitle", "")), + str(source.get("hostname", "")), + bool(source.get("checkHostname", False)), + ) + + +def _ensure_sources( + client: KarnakClient, + forward_node_id: int, + config: dict[str, Any], +) -> list[dict[str, Any]]: + requested = _require_list(config, "sourceNodes") + existing_keys = { + _source_key(item) + for item in client.list_source_nodes(forward_node_id) + if isinstance(item, dict) + } + summary: list[dict[str, Any]] = [] + for body in requested: + if not isinstance(body, dict): + raise KarnakConfigError("sourceNodes entries must be objects") + if not str(body.get("aeTitle", "")).strip(): + raise KarnakConfigError("sourceNodes[].aeTitle is required") + key = _source_key(body) + if key in existing_keys: + summary.append({"aeTitle": body.get("aeTitle"), "created": False}) + continue + created = client.add_source_node(forward_node_id, body) + existing_keys.add(key) + summary.append({"id": created.get("id"), "aeTitle": body.get("aeTitle"), "created": True}) + return summary + + +def _destination_key(destination: dict[str, Any]) -> tuple[str, str]: + dtype = str(destination.get("destinationType") or destination.get("type") or "").lower() + if dtype == "stow" or destination.get("url"): + return ("stow", str(destination.get("url", ""))) + return ("dicom", str(destination.get("aeTitle", ""))) + + +def _ensure_destinations( + client: KarnakClient, + forward_node_id: int, + project_id: int, + config: dict[str, Any], +) -> list[dict[str, Any]]: + requested = _require_list(config, "destinations") + existing_by_key = { + _destination_key(item): item + for item in client.list_destinations(forward_node_id) + if isinstance(item, dict) + } + summary: list[dict[str, Any]] = [] + for raw_body in requested: + if not isinstance(raw_body, dict): + raise KarnakConfigError("destinations entries must be objects") + body = dict(raw_body) + if body.get("desidentification") and not body.get("deIdentificationProject"): + body["deIdentificationProject"] = {"id": project_id} + if body.get("activateTagMorphing") and not body.get("tagMorphingProject"): + body["tagMorphingProject"] = {"id": project_id} + + key = _destination_key(body) + if not key[1]: + raise KarnakConfigError("destinations require aeTitle for DICOM or url for STOW-RS") + existing = existing_by_key.get(key) + if existing: + destination_id = int(existing["id"]) + merged = dict(existing) + merged.update(body) + client.update_destination(forward_node_id, destination_id, merged) + summary.append({"id": destination_id, "key": list(key), "created": False}) + else: + created = client.add_destination(forward_node_id, body) + destination_id = int(created.get("id", 0)) if isinstance(created, dict) else 0 + summary.append({"id": destination_id, "key": list(key), "created": True}) + return summary + + +def apply_config(client: KarnakClient, config: dict[str, Any]) -> dict[str, Any]: + profile = _ensure_profile(client, config) + project = _ensure_project(client, config, int(profile["id"])) + auth_configs = _ensure_auth_configs(client, config) + forward_node = _ensure_forward_node(client, config) + sources = _ensure_sources(client, int(forward_node["id"]), config) + destinations = _ensure_destinations(client, int(forward_node["id"]), int(project["id"]), config) + + external_rows = _load_external_ids(config) + external_import: dict[str, Any] | None = None + if external_rows: + external_import = client.import_external_ids(int(project["id"]), external_rows) + + return { + "profile": profile, + "project": project, + "auth_configs": auth_configs, + "forward_node": forward_node, + "source_nodes": sources, + "destinations": destinations, + "external_ids_import": external_import, + } diff --git a/python-client/karnak_api_client/client.py b/python-client/karnak_api_client/client.py new file mode 100644 index 00000000..2f5b3e1c --- /dev/null +++ b/python-client/karnak_api_client/client.py @@ -0,0 +1,450 @@ +"""Authenticated transport client for the Karnak REST API. + +The client intentionally mirrors the REST resources from API.md and keeps +policy decisions out of the transport layer. Higher-level code decides what +"apply" means (see ``karnak_api_client.apply``); this module only performs +authenticated HTTP calls and returns JSON-compatible dictionaries/lists. + +Authentication +-------------- +Depending on how the Karnak image is configured, the REST API is reachable +either with HTTP Basic (default in-memory IdP) or only through Spring +form-login (POST ``/login`` once, then carry the ``JSESSIONID`` cookie). +``auth_mode`` selects the behaviour: + +- ``"basic"`` — HTTP Basic on every request, no fallback. +- ``"form"`` — form-login before the first request, cookie afterwards. +- ``"auto"`` (default) — try Basic; when the server answers 401 or redirects + to ``/login``, switch to form-login transparently and retry once. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any + +import requests +from requests.auth import HTTPBasicAuth + +_AUTH_MODES = ("auto", "basic", "form") + + +class KarnakError(RuntimeError): + """Base class for all Karnak client errors (incl. network failures).""" + + +class AuthenticationError(KarnakError): + """Raised when login fails or the session is rejected.""" + + +class KarnakApiError(KarnakError): + """Raised when Karnak returns a non-success HTTP response.""" + + def __init__(self, method: str, url: str, status_code: int, body: str) -> None: + self.method = method + self.url = url + self.status_code = status_code + self.body = body + super().__init__(f"Karnak {method} {url} failed with HTTP {status_code}: {body}") + + +# Backwards-compatible alias kept for callers written against the original +# form-login client (TMLCTP harness, setup_deid_gateway.py). +ApiError = KarnakApiError + + +@dataclass(frozen=True) +class KarnakClientConfig: + base_url: str + username: str + password: str + timeout_sec: float = 30.0 + auth_mode: str = "auto" + + def __post_init__(self) -> None: + if self.auth_mode not in _AUTH_MODES: + raise ValueError(f"auth_mode must be one of {_AUTH_MODES}, got {self.auth_mode!r}") + + @classmethod + def from_env(cls) -> "KarnakClientConfig": + return cls( + base_url=os.getenv("KARNAK_API_BASE", "http://localhost:8081").rstrip("/"), + username=os.getenv("KARNAK_USERNAME", os.getenv("KARNAK_LOGIN_ADMIN", "admin")), + password=os.getenv("KARNAK_PASSWORD", "karnak"), + timeout_sec=float(os.getenv("KARNAK_API_TIMEOUT_SEC", "30")), + auth_mode=os.getenv("KARNAK_AUTH_MODE", "auto").strip().lower(), + ) + + +def _redirects_to_login(resp: requests.Response) -> bool: + if resp.status_code in (302, 303): + return "login" in resp.headers.get("Location", "") + return False + + +class KarnakClient: + """Thin wrapper around the Karnak REST API. + + Construction styles (equivalent):: + + KarnakClient() # from environment + KarnakClient(KarnakClientConfig(...)) # explicit config + KarnakClient(base_url=..., username=..., password=...) # kwargs + """ + + def __init__( + self, + config: KarnakClientConfig | None = None, + *, + base_url: str | None = None, + username: str | None = None, + password: str | None = None, + timeout_sec: float | None = None, + auth_mode: str | None = None, + session: requests.Session | None = None, + ) -> None: + cfg = config or KarnakClientConfig.from_env() + overrides = { + key: value + for key, value in { + "base_url": base_url.rstrip("/") if base_url else None, + "username": username, + "password": password, + "timeout_sec": timeout_sec, + "auth_mode": auth_mode, + }.items() + if value is not None + } + if overrides: + cfg = replace(cfg, **overrides) + self.config = cfg + self.session = session if session is not None else requests.Session() + self.session.headers.update({"Accept": "application/json"}) + self._basic = HTTPBasicAuth(cfg.username, cfg.password) + self._use_form = cfg.auth_mode == "form" + self._form_logged_in = False + + # ── authentication ────────────────────────────────────────────────── + + @property + def base(self) -> str: + """Base URL (kept as an attribute-style accessor for older callers).""" + return self.config.base_url + + def login(self) -> None: + """POST form credentials to /login and establish a session cookie.""" + try: + resp = self.session.post( + f"{self.config.base_url}/login", + data={"username": self.config.username, "password": self.config.password}, + allow_redirects=False, + timeout=self.config.timeout_sec, + ) + except requests.RequestException as exc: + raise KarnakError(f"Cannot reach {self.config.base_url}: {exc}") from exc + if resp.status_code not in (302, 303): + raise AuthenticationError(f"Unexpected login response: HTTP {resp.status_code}") + location = resp.headers.get("Location", "") + if "error" in location or location.rstrip("/").endswith("/login"): + raise AuthenticationError( + f"Login failed — invalid credentials for {self.config.username!r}" + ) + self._form_logged_in = True + + def invalidate_session(self) -> None: + """Drop the session cookie (e.g. after a Karnak container restart).""" + self.session.cookies.clear() + self._form_logged_in = False + + def check_connectivity(self, timeout: float = 5.0) -> None: + """Verify Karnak is reachable and credentials are accepted.""" + self._request("GET", "/api/forward-nodes", expected={200, 204}, timeout_sec=timeout) + + # ── transport ─────────────────────────────────────────────────────── + + def _url(self, path: str) -> str: + if not path.startswith("/"): + path = "/" + path + return f"{self.config.base_url}{path}" + + def _request( + self, + method: str, + path: str, + *, + json_body: Any | None = None, + files: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + expected: set[int] | None = None, + timeout_sec: float | None = None, + _form_retry: bool = False, + ) -> Any: + expected = expected or {200} + url = self._url(path) + if self._use_form and not self._form_logged_in: + self.login() + try: + resp = self.session.request( + method, + url, + json=json_body, + files=files, + params=params, + auth=None if self._use_form else self._basic, + allow_redirects=False, + timeout=timeout_sec or self.config.timeout_sec, + ) + except requests.RequestException as exc: + raise KarnakError(f"Karnak {method} {url} failed: {exc}") from exc + + unauthenticated = resp.status_code == 401 or _redirects_to_login(resp) + if unauthenticated and not self._use_form and not _form_retry: + if self.config.auth_mode == "basic": + raise KarnakApiError(method, url, resp.status_code, resp.text) + # auto mode: this Karnak build does not accept Basic on the API — + # switch to form-login for the rest of the client's lifetime. + self._use_form = True + self.login() + return self._request( + method, + path, + json_body=json_body, + files=files, + params=params, + expected=expected, + timeout_sec=timeout_sec, + _form_retry=True, + ) + if unauthenticated and self._use_form and not _form_retry: + # Session cookie expired (e.g. Karnak restarted): log in again once. + self.invalidate_session() + self.login() + return self._request( + method, + path, + json_body=json_body, + files=files, + params=params, + expected=expected, + timeout_sec=timeout_sec, + _form_retry=True, + ) + + if resp.status_code not in expected: + raise KarnakApiError(method, url, resp.status_code, resp.text) + if resp.status_code == 204 or not resp.content: + return None + content_type = resp.headers.get("Content-Type", "") + if "json" not in content_type.lower(): + return resp.text + return resp.json() + + # ── health / echo ─────────────────────────────────────────────────── + + def echo_destinations(self, src_aet: str) -> Any: + # This endpoint is intentionally unauthenticated server-side, but using + # session auth is harmless and keeps the client simple. + return self._request( + "GET", + "/api/echo/destinations", + params={"srcAet": src_aet}, + expected={200, 204}, + ) + + # ── profiles ──────────────────────────────────────────────────────── + + def list_profiles(self) -> list[dict[str, Any]]: + return self._request("GET", "/api/profiles", expected={200, 204}) or [] + + def upload_profile(self, path: Path | str) -> dict[str, Any]: + path = Path(path) + # Read up-front so the auto-mode form-login retry can resend the body. + payload = path.read_bytes() + return self._request( + "POST", + "/api/profiles", + files={"file": (path.name, payload, "application/x-yaml")}, + expected={201}, + timeout_sec=max(self.config.timeout_sec, 60.0), + ) + + def get_profile(self, profile_id: int) -> dict[str, Any]: + return self._request("GET", f"/api/profiles/{profile_id}", expected={200}) + + def update_profile(self, profile_id: int, body: dict[str, Any]) -> dict[str, Any]: + return self._request("PUT", f"/api/profiles/{profile_id}", json_body=body, expected={200}) + + def delete_profile(self, profile_id: int) -> None: + self._request("DELETE", f"/api/profiles/{profile_id}", expected={204}) + + # ── projects / secrets ────────────────────────────────────────────── + + def list_projects(self) -> list[dict[str, Any]]: + return self._request("GET", "/api/projects", expected={200, 204}) or [] + + def create_project(self, name: str, profile_id: int | None = None) -> dict[str, Any]: + body: dict[str, Any] = {"name": name} + if profile_id is not None: + body["profileId"] = profile_id + return self._request("POST", "/api/projects", json_body=body, expected={201}) + + def get_project(self, project_id: int) -> dict[str, Any]: + return self._request("GET", f"/api/projects/{project_id}", expected={200}) + + def update_project( + self, + project_id: int, + *, + name: str | None = None, + profile_id: int | None = None, + detach_profile: bool = False, + ) -> dict[str, Any]: + body: dict[str, Any] = {} + if name is not None: + body["name"] = name + if detach_profile: + body["profileId"] = None + elif profile_id is not None: + body["profileId"] = profile_id + return self._request("PUT", f"/api/projects/{project_id}", json_body=body, expected={200}) + + def delete_project(self, project_id: int) -> None: + self._request("DELETE", f"/api/projects/{project_id}", expected={204}) + + def add_project_secret(self, project_id: int, hex_key: str | None = None) -> dict[str, Any]: + body: dict[str, Any] = {} + if hex_key: + body["hexKey"] = hex_key + return self._request( + "POST", + f"/api/projects/{project_id}/secrets", + json_body=body, + expected={201}, + ) + + # Alias kept for callers written against the original form-login client. + generate_secret = add_project_secret + + def list_external_ids(self, project_id: int) -> list[dict[str, Any]]: + return self._request("GET", f"/api/projects/{project_id}/external-ids", expected={200}) or [] + + def import_external_ids(self, project_id: int, rows: list[dict[str, Any]]) -> dict[str, Any]: + return self._request( + "POST", + f"/api/projects/{project_id}/external-ids/import", + json_body=rows, + expected={200}, + ) + + # ── forward nodes / sources / destinations ────────────────────────── + + def list_forward_nodes(self) -> list[dict[str, Any]]: + return self._request("GET", "/api/forward-nodes", expected={200, 204}) or [] + + def get_forward_node(self, forward_node_id: int) -> dict[str, Any]: + return self._request("GET", f"/api/forward-nodes/{forward_node_id}", expected={200}) + + def create_forward_node(self, ae_title: str, description: str = "") -> dict[str, Any]: + return self._request( + "POST", + "/api/forward-nodes", + json_body={"fwdAeTitle": ae_title, "fwdDescription": description}, + expected={201}, + ) + + def update_forward_node( + self, + forward_node_id: int, + ae_title: str, + description: str = "", + ) -> dict[str, Any]: + return self._request( + "PUT", + f"/api/forward-nodes/{forward_node_id}", + json_body={"fwdAeTitle": ae_title, "fwdDescription": description}, + expected={200}, + ) + + def delete_forward_node(self, forward_node_id: int) -> None: + self._request("DELETE", f"/api/forward-nodes/{forward_node_id}", expected={204}) + + def list_source_nodes(self, forward_node_id: int) -> list[dict[str, Any]]: + return self._request( + "GET", + f"/api/forward-nodes/{forward_node_id}/source-nodes", + expected={200, 204}, + ) or [] + + def add_source_node(self, forward_node_id: int, body: dict[str, Any]) -> dict[str, Any]: + return self._request( + "POST", + f"/api/forward-nodes/{forward_node_id}/source-nodes", + json_body=body, + expected={201}, + ) + + def delete_source_node(self, forward_node_id: int, source_node_id: int) -> None: + self._request( + "DELETE", + f"/api/forward-nodes/{forward_node_id}/source-nodes/{source_node_id}", + expected={204}, + ) + + def list_destinations(self, forward_node_id: int) -> list[dict[str, Any]]: + return self._request( + "GET", + f"/api/forward-nodes/{forward_node_id}/destinations", + expected={200, 204}, + ) or [] + + def add_destination(self, forward_node_id: int, body: dict[str, Any]) -> dict[str, Any]: + return self._request( + "POST", + f"/api/forward-nodes/{forward_node_id}/destinations", + json_body=body, + expected={201}, + ) + + # Alias kept for callers written against the original form-login client. + create_destination = add_destination + + def update_destination( + self, + forward_node_id: int, + destination_id: int, + body: dict[str, Any], + ) -> dict[str, Any]: + return self._request( + "PUT", + f"/api/forward-nodes/{forward_node_id}/destinations/{destination_id}", + json_body=body, + expected={200}, + ) + + def delete_destination(self, forward_node_id: int, destination_id: int) -> None: + self._request( + "DELETE", + f"/api/forward-nodes/{forward_node_id}/destinations/{destination_id}", + expected={204}, + ) + + # ── auth configs ──────────────────────────────────────────────────── + + def list_auth_configs(self) -> list[dict[str, Any]]: + return self._request("GET", "/api/auth-configs", expected={200, 204}) or [] + + def create_auth_config(self, body: dict[str, Any]) -> dict[str, Any]: + return self._request("POST", "/api/auth-configs", json_body=body, expected={201}) + + def update_auth_config(self, code: str, body: dict[str, Any]) -> dict[str, Any]: + return self._request("PUT", f"/api/auth-configs/{code}", json_body=body, expected={200}) + + # ── monitoring ────────────────────────────────────────────────────── + + def query_transfers(self, params: dict[str, Any] | None = None) -> dict[str, Any]: + return self._request("GET", "/api/monitoring/transfers", params=params or {}, expected={200}) + + def count_transfers(self, params: dict[str, Any] | None = None) -> dict[str, Any]: + return self._request("GET", "/api/monitoring/transfers/count", params=params or {}, expected={200}) diff --git a/python-client/pyproject.toml b/python-client/pyproject.toml new file mode 100644 index 00000000..2eb2cc4e --- /dev/null +++ b/python-client/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "karnak-api-client" +version = "0.1.0" +description = "Python client for the Karnak REST API (karnak-endpoints fork): transport client plus idempotent desired-state apply for profiles, projects, forward nodes, destinations and auth configs." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "requests>=2.28", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", +] + +[tool.setuptools.packages.find] +include = ["karnak_api_client*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/python-client/tests/test_apply.py b/python-client/tests/test_apply.py new file mode 100644 index 00000000..28d48f05 --- /dev/null +++ b/python-client/tests/test_apply.py @@ -0,0 +1,105 @@ +"""Tests for idempotent Karnak apply orchestration. + +Ported from MiCo-BID-pipeline tests/unit/test_karnak_apply.py when the apply +layer moved into this package. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from karnak_api_client import apply_config + + +class FakeKarnakClient: + def __init__(self) -> None: + self.updated_destinations: list[dict[str, Any]] = [] + self.uploaded_profiles: list[Path] = [] + + def list_profiles(self) -> list[dict[str, Any]]: + return [{"id": 3, "name": "Profile A", "version": "1.0"}] + + def upload_profile(self, path: Path) -> dict[str, Any]: + self.uploaded_profiles.append(path) + return {"id": 99, "name": "Uploaded"} + + def list_projects(self) -> list[dict[str, Any]]: + return [{"id": 4, "name": "Project A"}] + + def update_project(self, project_id: int, **_kwargs: Any) -> dict[str, Any]: + return {"id": project_id} + + def get_project(self, project_id: int) -> dict[str, Any]: + return { + "id": project_id, + "secretEntities": [{"id": 1, "active": True}], + } + + def create_project(self, name: str, profile_id: int | None = None) -> dict[str, Any]: + return {"id": 44, "name": name, "profileId": profile_id} + + def add_project_secret(self, project_id: int, hex_key: str | None = None) -> dict[str, Any]: + return {"projectId": project_id, "hexKey": hex_key or "generated", "active": True} + + def list_auth_configs(self) -> list[dict[str, Any]]: + return [] + + def list_forward_nodes(self) -> list[dict[str, Any]]: + return [{"id": 5, "fwdAeTitle": "KARNAK-GATEWAY"}] + + def update_forward_node(self, forward_node_id: int, ae_title: str, description: str = "") -> dict[str, Any]: + return {"id": forward_node_id, "fwdAeTitle": ae_title, "fwdDescription": description} + + def create_forward_node(self, ae_title: str, description: str = "") -> dict[str, Any]: + return {"id": 55, "fwdAeTitle": ae_title, "fwdDescription": description} + + def list_source_nodes(self, _forward_node_id: int) -> list[dict[str, Any]]: + return [] + + def list_destinations(self, _forward_node_id: int) -> list[dict[str, Any]]: + return [{"id": 6, "destinationType": "dicom", "aeTitle": "LOCAL-STORAGE"}] + + def update_destination( + self, + _forward_node_id: int, + destination_id: int, + body: dict[str, Any], + ) -> dict[str, Any]: + self.updated_destinations.append({"id": destination_id, **body}) + return {"id": destination_id, **body} + + def add_destination(self, _forward_node_id: int, body: dict[str, Any]) -> dict[str, Any]: + return {"id": 66, **body} + + +def test_apply_config_reuses_existing_resources_and_links_destination(tmp_path: Path) -> None: + profile = tmp_path / "profile.yml" + profile.write_text("name: Profile A\nversion: '1.0'\n", encoding="utf-8") + client = FakeKarnakClient() + + summary = apply_config( + client, # type: ignore[arg-type] + { + "profile": {"path": str(profile), "name": "Profile A", "version": "1.0"}, + "project": {"name": "Project A", "ensureSecret": True}, + "forwardNode": {"aeTitle": "KARNAK-GATEWAY"}, + "sourceNodes": [], + "destinations": [ + { + "destinationType": "dicom", + "aeTitle": "LOCAL-STORAGE", + "hostname": "dicom_receiver", + "port": 11104, + "activate": True, + "desidentification": True, + } + ], + }, + ) + + assert summary["profile"]["created"] is False + assert summary["project"]["created"] is False + assert summary["project"]["secret_created"] is False + assert client.uploaded_profiles == [] + assert client.updated_destinations[0]["deIdentificationProject"] == {"id": 4} diff --git a/python-client/tests/test_client.py b/python-client/tests/test_client.py new file mode 100644 index 00000000..d9a4c353 --- /dev/null +++ b/python-client/tests/test_client.py @@ -0,0 +1,252 @@ +"""Unit tests for the unified Karnak client (auth modes + transport).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +import requests + +from karnak_api_client import ( + AuthenticationError, + KarnakApiError, + KarnakClient, + KarnakClientConfig, + KarnakError, +) + +BASE = "http://karnak.test:8081" + + +def make_config(auth_mode: str = "auto") -> KarnakClientConfig: + return KarnakClientConfig( + base_url=BASE, username="admin", password="karnak", auth_mode=auth_mode + ) + + +class FakeResponse: + def __init__( + self, + status_code: int, + body: Any = None, + headers: dict[str, str] | None = None, + ) -> None: + self.status_code = status_code + self.headers = headers or {} + if body is None: + self.content = b"" + self.text = "" + elif isinstance(body, str): + self.content = body.encode() + self.text = body + self.headers.setdefault("Content-Type", "text/html") + else: + self.text = json.dumps(body) + self.content = self.text.encode() + self.headers.setdefault("Content-Type", "application/json") + self._body = body + + def json(self) -> Any: + return self._body + + +class FakeCookies: + def __init__(self) -> None: + self.cleared = 0 + + def clear(self) -> None: + self.cleared += 1 + + +class FakeSession: + """Queue-driven stand-in for requests.Session.""" + + def __init__(self, responses: list[FakeResponse]) -> None: + self.responses = list(responses) + self.calls: list[dict[str, Any]] = [] + self.headers: dict[str, str] = {} + self.cookies = FakeCookies() + + def request(self, method: str, url: str, **kwargs: Any) -> FakeResponse: + self.calls.append({"method": method, "url": url, **kwargs}) + if not self.responses: + raise AssertionError(f"Unexpected request: {method} {url}") + return self.responses.pop(0) + + def post(self, url: str, **kwargs: Any) -> FakeResponse: + return self.request("POST", url, **kwargs) + + +def make_client(responses: list[FakeResponse], auth_mode: str = "auto") -> tuple[KarnakClient, FakeSession]: + session = FakeSession(responses) + client = KarnakClient(make_config(auth_mode), session=session) # type: ignore[arg-type] + return client, session + + +def login_ok() -> FakeResponse: + return FakeResponse(302, headers={"Location": f"{BASE}/"}) + + +def login_redirect() -> FakeResponse: + return FakeResponse(302, headers={"Location": f"{BASE}/login"}) + + +# ── basic mode ────────────────────────────────────────────────────────────── + + +def test_basic_mode_sends_basic_auth_and_parses_json() -> None: + client, session = make_client([FakeResponse(200, [{"id": 1}])], auth_mode="basic") + assert client.list_profiles() == [{"id": 1}] + call = session.calls[0] + assert call["auth"] is not None + assert call["allow_redirects"] is False + + +def test_basic_mode_does_not_fall_back_on_401() -> None: + client, session = make_client([FakeResponse(401, "nope")], auth_mode="basic") + with pytest.raises(KarnakApiError) as exc: + client.list_profiles() + assert exc.value.status_code == 401 + assert len(session.calls) == 1 + + +# ── auto mode fallback ────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("first", [login_redirect(), FakeResponse(401, "nope")]) +def test_auto_mode_falls_back_to_form_login(first: FakeResponse) -> None: + client, session = make_client([first, login_ok(), FakeResponse(200, [{"id": 7}])]) + assert client.list_profiles() == [{"id": 7}] + methods = [(c["method"], c["url"]) for c in session.calls] + assert methods == [ + ("GET", f"{BASE}/api/profiles"), + ("POST", f"{BASE}/login"), + ("GET", f"{BASE}/api/profiles"), + ] + # After fallback the client stays in form mode: no Basic auth on retry. + assert session.calls[2]["auth"] is None + # Subsequent calls skip Basic entirely without a new login. + session.responses = [FakeResponse(200, [])] + client.list_projects() + assert session.calls[3]["auth"] is None + + +def test_auto_mode_raises_authentication_error_on_bad_credentials() -> None: + client, _ = make_client( + [login_redirect(), FakeResponse(302, headers={"Location": f"{BASE}/login?error"})] + ) + with pytest.raises(AuthenticationError): + client.list_profiles() + + +# ── form mode ─────────────────────────────────────────────────────────────── + + +def test_form_mode_logs_in_before_first_request() -> None: + client, session = make_client([login_ok(), FakeResponse(200, [])], auth_mode="form") + client.list_profiles() + methods = [(c["method"], c["url"]) for c in session.calls] + assert methods[0] == ("POST", f"{BASE}/login") + assert session.calls[1]["auth"] is None + + +def test_form_mode_relogins_when_session_expires() -> None: + client, session = make_client( + [login_ok(), login_redirect(), login_ok(), FakeResponse(200, [{"id": 2}])], + auth_mode="form", + ) + assert client.list_profiles() == [{"id": 2}] + assert session.cookies.cleared == 1 + methods = [(c["method"], c["url"]) for c in session.calls] + assert methods == [ + ("POST", f"{BASE}/login"), + ("GET", f"{BASE}/api/profiles"), + ("POST", f"{BASE}/login"), + ("GET", f"{BASE}/api/profiles"), + ] + + +def test_invalidate_session_clears_cookies_and_forces_relogin() -> None: + client, session = make_client( + [login_ok(), FakeResponse(200, []), login_ok(), FakeResponse(200, [])], + auth_mode="form", + ) + client.list_profiles() + client.invalidate_session() + client.list_profiles() + posts = [c for c in session.calls if c["method"] == "POST"] + assert len(posts) == 2 + + +# ── transport behaviour ───────────────────────────────────────────────────── + + +def test_204_responses_map_to_empty_list() -> None: + client, _ = make_client([FakeResponse(204)], auth_mode="basic") + assert client.list_forward_nodes() == [] + + +def test_unexpected_status_raises_api_error_with_details() -> None: + client, _ = make_client([FakeResponse(500, "boom")], auth_mode="basic") + with pytest.raises(KarnakApiError) as exc: + client.get_project(3) + assert exc.value.status_code == 500 + assert exc.value.method == "GET" + assert "boom" in exc.value.body + + +def test_connection_error_wrapped_as_karnak_error() -> None: + class ExplodingSession(FakeSession): + def request(self, method: str, url: str, **kwargs: Any) -> FakeResponse: + raise requests.ConnectionError("refused") + + session = ExplodingSession([]) + client = KarnakClient(make_config("basic"), session=session) # type: ignore[arg-type] + with pytest.raises(KarnakError): + client.check_connectivity() + + +def test_upload_profile_accepts_str_and_survives_form_fallback(tmp_path: Path) -> None: + profile = tmp_path / "p.yml" + profile.write_bytes(b"name: X\n") + client, session = make_client( + [login_redirect(), login_ok(), FakeResponse(201, {"id": 12, "name": "X"})] + ) + created = client.upload_profile(str(profile)) + assert created["id"] == 12 + # Both the first attempt and the retry carried the file payload. + uploads = [c for c in session.calls if c["url"].endswith("/api/profiles")] + for call in uploads: + name, payload, content_type = call["files"]["file"] + assert name == "p.yml" + assert payload == b"name: X\n" + assert content_type == "application/x-yaml" + + +# ── compatibility surface ─────────────────────────────────────────────────── + + +def test_aliases_point_at_canonical_methods() -> None: + assert KarnakClient.generate_secret is KarnakClient.add_project_secret + assert KarnakClient.create_destination is KarnakClient.add_destination + + +def test_kwargs_constructor_and_base_property() -> None: + session = FakeSession([]) + client = KarnakClient( + base_url=f"{BASE}/", + username="u", + password="p", + auth_mode="form", + session=session, # type: ignore[arg-type] + ) + assert client.base == BASE + assert client.config.username == "u" + assert client.config.auth_mode == "form" + + +def test_invalid_auth_mode_rejected() -> None: + with pytest.raises(ValueError): + KarnakClientConfig(base_url=BASE, username="u", password="p", auth_mode="oauth") diff --git a/setup_deid_gateway.py b/setup_deid_gateway.py new file mode 100644 index 00000000..5adac66e --- /dev/null +++ b/setup_deid_gateway.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +""" +setup_deid_gateway.py +--------------------- +Automates three steps against the Karnak REST API: + 1. Upload a de-identification profile YAML file + 2. Create a project linked to that profile (+ generate an HMAC secret) + 3. Add a de-identified DICOM destination to a forward node using that project + +Uses form-login session authentication via _karnak_api.KarnakClient. + +Usage +----- + python setup_deid_gateway.py --profile my-profile.yml \ + --project-name "Clinical Trial 2024" \ + --fwd-aet GATEWAY1 \ + --dest-aet PACS_ANON \ + --dest-host 192.168.1.50 \ + --dest-port 11112 + + # Pseudonym from a DICOM tag: + python setup_deid_gateway.py ... --pseudonym-type EXTID_IN_TAG \ + --pseudonym-tag 00100010 --pseudonym-delimiter "^" --pseudonym-position 0 + + # Use an existing forward node by ID instead of creating one: + python setup_deid_gateway.py --profile my-profile.yml \ + --project-name "Clinical Trial 2024" \ + --fwd-id 3 \ + --dest-aet PACS_ANON \ + --dest-host 192.168.1.50 \ + --dest-port 11112 +""" + +import argparse +import sys +from pathlib import Path + +# The client lives in python-client/ (installable as karnak-api-client); the +# path insert lets this script run from a bare checkout without pip install. +sys.path.insert(0, str(Path(__file__).resolve().parent / "python-client")) + +from karnak_api_client import ApiError, AuthenticationError, KarnakClient, KarnakError # noqa: E402 + +PSEUDONYM_TYPES = ("CACHE_EXTID", "EXTID_IN_TAG", "EXTID_API") + + +def die(msg: str) -> None: + print(f"ERROR: {msg}", file=sys.stderr) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Step 1: Upload profile YAML +# --------------------------------------------------------------------------- + +def upload_profile(client: KarnakClient, profile_path: str) -> int: + print(f"[1/3] Uploading profile: {profile_path}") + data = client.upload_profile(profile_path) + profile_id = data["id"] + print(f" Profile uploaded id={profile_id} name={data.get('name', '?')}") + return profile_id + + +# --------------------------------------------------------------------------- +# Step 2: Create project + HMAC secret +# --------------------------------------------------------------------------- + +def create_project(client: KarnakClient, name: str, profile_id: int) -> int: + print(f"[2/3] Creating project: {name!r} (profileId={profile_id})") + data = client.create_project(name, profile_id) + project_id = data["id"] + print(f" Project created id={project_id}") + + secret = client.generate_secret(project_id) + print(f" HMAC secret {secret.get('displayKey', '(generated)')}") + + return project_id + + +# --------------------------------------------------------------------------- +# Destination payload (de-id + pseudonym type) +# --------------------------------------------------------------------------- + +def build_destination_payload( + *, + project_id: int, + dest_description: str, + dest_aet: str, + dest_host: str, + dest_port: int, + pseudonym_type: str, + issuer_by_default: str | None, + pseudonym_tag: str | None, + pseudonym_delimiter: str | None, + pseudonym_position: int | None, + save_pseudonym: bool | None, + pseudonym_url: str | None, + pseudonym_method: str | None, + pseudonym_response_path: str | None, + pseudonym_body: str | None, + pseudonym_auth_config: str | None, +) -> dict: + payload: dict = { + "destinationType": "dicom", + "description": dest_description, + "aeTitle": dest_aet, + "hostname": dest_host, + "port": dest_port, + "activate": True, + "desidentification": True, + "deIdentificationProject": {"id": project_id}, + "pseudonymType": pseudonym_type, + } + if issuer_by_default: + payload["issuerByDefault"] = issuer_by_default + + if pseudonym_type == "EXTID_IN_TAG": + payload["tag"] = pseudonym_tag + if pseudonym_delimiter is not None: + payload["delimiter"] = pseudonym_delimiter + if pseudonym_position is not None: + payload["position"] = pseudonym_position + if save_pseudonym is not None: + payload["savePseudonym"] = save_pseudonym + elif pseudonym_type == "EXTID_API": + payload["pseudonymUrl"] = pseudonym_url + payload["method"] = pseudonym_method + payload["responsePath"] = pseudonym_response_path + if pseudonym_body: + payload["body"] = pseudonym_body + if pseudonym_auth_config: + payload["authConfig"] = pseudonym_auth_config + + return payload + + +def validate_pseudonym_args(args: argparse.Namespace) -> None: + if args.pseudonym_type == "EXTID_IN_TAG": + if not args.pseudonym_tag: + die("--pseudonym-tag is required when --pseudonym-type is EXTID_IN_TAG") + if args.pseudonym_position is not None and args.pseudonym_position > 0 and not args.pseudonym_delimiter: + die("--pseudonym-delimiter is required when --pseudonym-position > 0") + if args.pseudonym_delimiter and args.pseudonym_position is None: + die("--pseudonym-position is required when --pseudonym-delimiter is set") + elif args.pseudonym_type == "EXTID_API": + missing = [ + name + for name, value in ( + ("--pseudonym-url", args.pseudonym_url), + ("--pseudonym-method", args.pseudonym_method), + ("--pseudonym-response-path", args.pseudonym_response_path), + ) + if not value + ] + if missing: + die(f"{', '.join(missing)} required when --pseudonym-type is EXTID_API") + if args.pseudonym_method == "POST" and not args.pseudonym_body: + die("--pseudonym-body is required when --pseudonym-method is POST") + + +# --------------------------------------------------------------------------- +# Step 3: Resolve / create forward node, then add de-identified destination +# --------------------------------------------------------------------------- + +def setup_gateway( + client: KarnakClient, + project_id: int, + fwd_id: int | None, + fwd_aet: str | None, + fwd_description: str, + dest_aet: str, + dest_host: str, + dest_port: int, + dest_description: str, + pseudonym_type: str, + issuer_by_default: str | None, + pseudonym_tag: str | None, + pseudonym_delimiter: str | None, + pseudonym_position: int | None, + save_pseudonym: bool | None, + pseudonym_url: str | None, + pseudonym_method: str | None, + pseudonym_response_path: str | None, + pseudonym_body: str | None, + pseudonym_auth_config: str | None, +) -> None: + print("[3/3] Configuring gateway destination with de-identification") + + if fwd_id is not None: + node = client.get_forward_node(fwd_id) + print(f" Using existing forward node id={fwd_id} aet={node.get('fwdAeTitle', '?')}") + else: + if not fwd_aet: + die("Provide --fwd-id or --fwd-aet to identify the forward node.") + try: + node = client.create_forward_node(fwd_aet, fwd_description) + fwd_id = node["id"] + print(f" Forward node created id={fwd_id} aet={fwd_aet}") + except ApiError as exc: + if exc.status_code != 409: + raise + # Already exists — find it by listing + all_nodes = client.list_forward_nodes() + matches = [n for n in all_nodes if n.get("fwdAeTitle") == fwd_aet] + if not matches: + die(f"Forward node AET {fwd_aet!r} conflict but not found in list.") + node = matches[0] + fwd_id = node["id"] + print(f" Forward node already exists id={fwd_id} aet={fwd_aet}") + + dest_payload = build_destination_payload( + project_id=project_id, + dest_description=dest_description, + dest_aet=dest_aet, + dest_host=dest_host, + dest_port=dest_port, + pseudonym_type=pseudonym_type, + issuer_by_default=issuer_by_default, + pseudonym_tag=pseudonym_tag, + pseudonym_delimiter=pseudonym_delimiter, + pseudonym_position=pseudonym_position, + save_pseudonym=save_pseudonym, + pseudonym_url=pseudonym_url, + pseudonym_method=pseudonym_method, + pseudonym_response_path=pseudonym_response_path, + pseudonym_body=pseudonym_body, + pseudonym_auth_config=pseudonym_auth_config, + ) + dest = client.create_destination(fwd_id, dest_payload) + print( + f" Destination created id={dest.get('id', '?')} " + f"aet={dest_aet} host={dest_host}:{dest_port}" + ) + print(" De-identification: ENABLED") + print(f" Pseudonym type: {pseudonym_type}") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser( + description="Upload a profile, create a project, and wire it to a Karnak de-id gateway." + ) + p.add_argument("--base-url", default="http://localhost:8081", help="Karnak base URL") + p.add_argument("--user", default="admin", help="Login username") + p.add_argument("--password", default="karnak", help="Login password") + + p.add_argument("--profile", required=True, metavar="FILE", help="Path to profile YAML file") + p.add_argument("--project-name", required=True, metavar="NAME", help="Project name to create") + + fwd = p.add_mutually_exclusive_group(required=True) + fwd.add_argument("--fwd-id", type=int, metavar="ID", help="Existing forward node ID") + fwd.add_argument("--fwd-aet", metavar="AET", help="Forward node AE Title (create if absent)") + + p.add_argument("--fwd-description", default="De-identification gateway", metavar="TEXT") + + p.add_argument("--dest-aet", required=True, metavar="AET", help="Destination AE Title") + p.add_argument("--dest-host", required=True, metavar="HOST", help="Destination hostname/IP") + p.add_argument("--dest-port", required=True, type=int, metavar="PORT", help="Destination port") + p.add_argument( + "--dest-description", default="De-identified DICOM archive", metavar="TEXT" + ) + + p.add_argument( + "--pseudonym-type", + default="CACHE_EXTID", + choices=PSEUDONYM_TYPES, + help="How to resolve the external pseudonym (default: CACHE_EXTID)", + ) + p.add_argument( + "--issuer-by-default", + default=None, + metavar="TEXT", + help="Default Issuer of Patient ID for de-identification", + ) + + tag = p.add_argument_group("EXTID_IN_TAG options") + tag.add_argument("--pseudonym-tag", metavar="TAG", help="DICOM tag, 8 hex digits (e.g. 00100010)") + tag.add_argument("--pseudonym-delimiter", metavar="CHAR", help="Delimiter to split tag value") + tag.add_argument("--pseudonym-position", type=int, metavar="N", help="Index after split (0-based)") + tag.add_argument( + "--save-pseudonym", + action="store_true", + help="Store pseudonym read from tag in project cache", + ) + + api = p.add_argument_group("EXTID_API options") + api.add_argument("--pseudonym-url", metavar="URL", help="External API URL") + api.add_argument( + "--pseudonym-method", + choices=("GET", "POST"), + help="HTTP method (required for EXTID_API)", + ) + api.add_argument( + "--pseudonym-response-path", metavar="PATH", help="JSON path to pseudonym in response" + ) + api.add_argument("--pseudonym-body", metavar="JSON", help="Request body (required for POST)") + api.add_argument( + "--pseudonym-auth-config", + metavar="CODE", + help="Auth config code from /api/auth-configs", + ) + + return p.parse_args() + + +def main() -> None: + args = parse_args() + validate_pseudonym_args(args) + + client = KarnakClient( + base_url=args.base_url, + username=args.user, + password=args.password, + ) + + try: + client.check_connectivity() + except AuthenticationError as exc: + die(str(exc)) + except KarnakError as exc: + die(str(exc)) + + try: + profile_id = upload_profile(client, args.profile) + project_id = create_project(client, args.project_name, profile_id) + setup_gateway( + client=client, + project_id=project_id, + fwd_id=args.fwd_id, + fwd_aet=args.fwd_aet, + fwd_description=args.fwd_description, + dest_aet=args.dest_aet, + dest_host=args.dest_host, + dest_port=args.dest_port, + dest_description=args.dest_description, + pseudonym_type=args.pseudonym_type, + issuer_by_default=args.issuer_by_default, + pseudonym_tag=args.pseudonym_tag, + pseudonym_delimiter=args.pseudonym_delimiter, + pseudonym_position=args.pseudonym_position, + save_pseudonym=args.save_pseudonym if args.save_pseudonym else None, + pseudonym_url=args.pseudonym_url, + pseudonym_method=args.pseudonym_method, + pseudonym_response_path=args.pseudonym_response_path, + pseudonym_body=args.pseudonym_body, + pseudonym_auth_config=args.pseudonym_auth_config, + ) + except (ApiError, KarnakError) as exc: + die(str(exc)) + + print("\nDone.") + print(f" Profile ID : {profile_id}") + print(f" Project ID : {project_id}") + + +if __name__ == "__main__": + main() From 5a8e9ca0a2bcfdd25090bef2498f89a007c45cea Mon Sep 17 00:00:00 2001 From: jbardet Date: Sun, 19 Jul 2026 21:01:31 +0200 Subject: [PATCH 3/3] fix: unblock the management REST API under live testing Found by driving the ported REST API through a real downstream pipeline before opening the upstream PR: - /api/** had no explicit authorization rule, so authenticated requests fell through to VaadinSecurityConfigurer's route-based access control, which denies (403) any route it doesn't recognize as a Vaadin view. Add an explicit requestMatchers("/api/**").authenticated() rule in both SecurityInMemoryConfig and SecurityConfiguration (OIDC). - Spring Security's default CSRF protection rejected every POST/PUT/ DELETE to /api/** from non-browser Basic-auth clients (no CSRF token available), making the API's actual write operations unusable for its intended scripted/automated callers. Exempt /api/** from CSRF, the conventional treatment for a stateless REST API. - ArgumentEntity.profileElementEntity, TagEntity.profileElementEntity, ProfileEntity.projectEntities, and KheopsAlbumsEntity.destinationEntity are bidirectional JPA back-references that were missing @JsonIgnore. Since the forward side of each relationship is already reachable from a REST response (profile elements, tags/arguments, project<->profile, destination<->kheopsAlbums), Jackson recursed through the cycle indefinitely, producing truncated/invalid JSON on GET /api/projects and GET /api/forward-nodes instead of a 200 response. Verified live against the ported build: GET now returns 200/204 instead of 403, POST returns normal validation responses instead of 403, and GET /api/projects returns clean, complete JSON instead of a truncated multi-hundred-level-deep recursive body. --- .../backend/config/SecurityConfiguration.java | 16 +++++++++++++++- .../backend/config/SecurityInMemoryConfig.java | 16 +++++++++++++++- .../backend/data/entity/ArgumentEntity.java | 1 + .../backend/data/entity/KheopsAlbumsEntity.java | 2 ++ .../backend/data/entity/ProfileEntity.java | 1 + .../karnak/backend/data/entity/TagEntity.java | 2 ++ 6 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/karnak/backend/config/SecurityConfiguration.java b/src/main/java/org/karnak/backend/config/SecurityConfiguration.java index 98ceeff4..0357589a 100644 --- a/src/main/java/org/karnak/backend/config/SecurityConfiguration.java +++ b/src/main/java/org/karnak/backend/config/SecurityConfiguration.java @@ -80,7 +80,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .permitAll() // Allow endpoints .requestMatchers(HttpMethod.GET, "/api/echo/destinations") - .permitAll()) + .permitAll() + // Management REST API: require authentication explicitly. Without + // this, requests fall through to VaadinSecurityConfigurer's own + // route-based access control below, which treats /api/** as an + // unrecognized Vaadin route and denies it (403) even for an + // authenticated user, instead of just requiring authentication. + .requestMatchers("/api/**") + .authenticated()) + // The management REST API is called by non-browser clients over HTTP + // Basic/Bearer, which never carry the CSRF token a browser session + // would. Spring Security's default CSRF protection would otherwise + // reject every POST/PUT/DELETE call to /api/** with 403, regardless of + // valid credentials, so exempt it the same way a stateless REST API + // conventionally is exempted. + .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**")) // OpenId connect login: map the IDP realm/client roles to the Karnak roles // so that @RolesAllowed annotations on the views work with OIDC users. The // roles are read from the Bearer/access token (not the ID token) via a diff --git a/src/main/java/org/karnak/backend/config/SecurityInMemoryConfig.java b/src/main/java/org/karnak/backend/config/SecurityInMemoryConfig.java index 04b14cae..565e83a0 100644 --- a/src/main/java/org/karnak/backend/config/SecurityInMemoryConfig.java +++ b/src/main/java/org/karnak/backend/config/SecurityInMemoryConfig.java @@ -62,7 +62,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .permitAll() // Allow endpoints .requestMatchers(HttpMethod.GET, "/api/echo/destinations") - .permitAll()) + .permitAll() + // Management REST API: require authentication explicitly. Without + // this, requests fall through to VaadinSecurityConfigurer's own + // route-based access control below, which treats /api/** as an + // unrecognized Vaadin route and denies it (403) even for an + // authenticated user, instead of just requiring authentication. + .requestMatchers("/api/**") + .authenticated()) + // The management REST API is called by non-browser clients over HTTP + // Basic, which never carry the CSRF token a browser session would. + // Spring Security's default CSRF protection would otherwise reject + // every POST/PUT/DELETE call to /api/** with 403, regardless of valid + // credentials, so exempt it the same way a stateless REST API + // conventionally is exempted. + .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**")) // Enable HTTP Basic authentication so the management REST API (/api/**, // beyond the explicitly permitted echo endpoint) can be called by // non-browser clients, alongside the Vaadin/form-login session below. diff --git a/src/main/java/org/karnak/backend/data/entity/ArgumentEntity.java b/src/main/java/org/karnak/backend/data/entity/ArgumentEntity.java index ec073a3e..d5b70634 100644 --- a/src/main/java/org/karnak/backend/data/entity/ArgumentEntity.java +++ b/src/main/java/org/karnak/backend/data/entity/ArgumentEntity.java @@ -43,6 +43,7 @@ public class ArgumentEntity implements Serializable { @ManyToOne @JoinColumn(name = "profile_element_id", nullable = false) + @JsonIgnore private ProfileElementEntity profileElementEntity; private String argumentKey; diff --git a/src/main/java/org/karnak/backend/data/entity/KheopsAlbumsEntity.java b/src/main/java/org/karnak/backend/data/entity/KheopsAlbumsEntity.java index 745d6021..4a1cf809 100644 --- a/src/main/java/org/karnak/backend/data/entity/KheopsAlbumsEntity.java +++ b/src/main/java/org/karnak/backend/data/entity/KheopsAlbumsEntity.java @@ -20,6 +20,7 @@ import java.io.Serializable; import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Getter; import lombok.Setter; import org.jspecify.annotations.NullUnmarked; @@ -48,6 +49,7 @@ public class KheopsAlbumsEntity implements Serializable { @ManyToOne @JoinColumn(name = "destination_id") + @JsonIgnore private DestinationEntity destinationEntity = new DestinationEntity(); public KheopsAlbumsEntity() { diff --git a/src/main/java/org/karnak/backend/data/entity/ProfileEntity.java b/src/main/java/org/karnak/backend/data/entity/ProfileEntity.java index 799dcfc3..d0a153c4 100644 --- a/src/main/java/org/karnak/backend/data/entity/ProfileEntity.java +++ b/src/main/java/org/karnak/backend/data/entity/ProfileEntity.java @@ -74,6 +74,7 @@ public class ProfileEntity implements Serializable { private Set maskEntities = new HashSet<>(); @OneToMany(mappedBy = "profileEntity", fetch = FetchType.EAGER) + @JsonIgnore private List projectEntities; // Optional organizational group (null = shown at the root of the list) diff --git a/src/main/java/org/karnak/backend/data/entity/TagEntity.java b/src/main/java/org/karnak/backend/data/entity/TagEntity.java index f0a6592b..44d8160d 100644 --- a/src/main/java/org/karnak/backend/data/entity/TagEntity.java +++ b/src/main/java/org/karnak/backend/data/entity/TagEntity.java @@ -22,6 +22,7 @@ import java.io.Serial; import java.io.Serializable; import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Getter; import lombok.Setter; import org.hibernate.annotations.DiscriminatorOptions; @@ -48,6 +49,7 @@ public abstract class TagEntity implements Serializable { @ManyToOne @JoinColumn(name = "profile_element_id", nullable = false) + @JsonIgnore private ProfileElementEntity profileElementEntity; private String tagValue;