diff --git a/src/main/java/com/metaformsystems/redline/api/controller/TenantController.java b/src/main/java/com/metaformsystems/redline/api/controller/TenantController.java index 42d6cb5..806d0d8 100644 --- a/src/main/java/com/metaformsystems/redline/api/controller/TenantController.java +++ b/src/main/java/com/metaformsystems/redline/api/controller/TenantController.java @@ -16,6 +16,7 @@ import com.metaformsystems.redline.api.dto.request.DataPlaneRegistrationRequest; import com.metaformsystems.redline.api.dto.request.ParticipantDeployment; +import com.metaformsystems.redline.api.dto.request.PartnerReferenceRequest; import com.metaformsystems.redline.api.dto.request.ServiceProvider; import com.metaformsystems.redline.api.dto.request.TenantRegistration; import com.metaformsystems.redline.api.dto.response.Dataspace; @@ -202,6 +203,29 @@ public ResponseEntity> getPartners(@PathVariable Long pro return ResponseEntity.ok(references); } + @PostMapping("service-providers/{providerId}/tenants/{tenantId}/participants/{participantId}/partners/{dataspaceId}") +// @PreAuthorize("hasRole('USER')") + @Operation(summary = "Create partner reference", description = "Creates a new partner reference for a participant in a specific dataspace") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Partner reference successfully created", + content = @Content(schema = @Schema(implementation = PartnerReference.class))), + @ApiResponse(responseCode = "400", description = "Invalid partner reference data"), + @ApiResponse(responseCode = "404", description = "Service provider, tenant, participant, or dataspace not found") + }) + @Parameter(name = "providerId", description = "Database ID of the service provider", required = true) + @Parameter(name = "tenantId", description = "Database ID of the tenant", required = true) + @Parameter(name = "participantId", description = "Database ID of the participant", required = true) + @Parameter(name = "dataspaceId", description = "Database ID of the dataspace", required = true) + public ResponseEntity createPartner(@PathVariable Long providerId, + @PathVariable Long tenantId, + @PathVariable Long participantId, + @PathVariable Long dataspaceId, + @RequestBody PartnerReferenceRequest request) { + var partnerReference = tenantService.createPartnerReference(providerId, tenantId, participantId, dataspaceId, request); + // TODO auth check for provider access + return ResponseEntity.ok(partnerReference); + } + @GetMapping("service-providers/{serviceProviderId}/tenants/{tenantId}/participants/{participantId}/dataspaces") // @PreAuthorize("hasRole('USER')") @Operation(summary = "Get participant dataspaces", description = "Retrieves a list of dataspaces associated with a specific participant") diff --git a/src/main/java/com/metaformsystems/redline/api/dto/request/PartnerReferenceRequest.java b/src/main/java/com/metaformsystems/redline/api/dto/request/PartnerReferenceRequest.java new file mode 100644 index 0000000..7f61fa7 --- /dev/null +++ b/src/main/java/com/metaformsystems/redline/api/dto/request/PartnerReferenceRequest.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Metaform Systems, Inc. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package com.metaformsystems.redline.api.dto.request; + +import java.util.Map; + +/** + * Request DTO for creating a partner reference. + */ +public record PartnerReferenceRequest( + String identifier, + String nickname, + Map properties +) { + public PartnerReferenceRequest(String identifier, String nickname) { + this(identifier, nickname, Map.of()); + } +} diff --git a/src/main/java/com/metaformsystems/redline/api/dto/response/PartnerReference.java b/src/main/java/com/metaformsystems/redline/api/dto/response/PartnerReference.java index ba11021..28d5e3d 100644 --- a/src/main/java/com/metaformsystems/redline/api/dto/response/PartnerReference.java +++ b/src/main/java/com/metaformsystems/redline/api/dto/response/PartnerReference.java @@ -14,5 +14,7 @@ package com.metaformsystems.redline.api.dto.response; -public record PartnerReference(String identifier, String nickname) { +import java.util.Map; + +public record PartnerReference(String identifier, String nickname, Map properties) { } diff --git a/src/main/java/com/metaformsystems/redline/domain/entity/PartnerReference.java b/src/main/java/com/metaformsystems/redline/domain/entity/PartnerReference.java index 7574076..9c59cc0 100644 --- a/src/main/java/com/metaformsystems/redline/domain/entity/PartnerReference.java +++ b/src/main/java/com/metaformsystems/redline/domain/entity/PartnerReference.java @@ -14,11 +14,34 @@ package com.metaformsystems.redline.domain.entity; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.Embeddable; +import java.util.HashMap; +import java.util.Map; + /** * A reference to a partner organization. The identifier is the participant identifier such as a DID. */ @Embeddable -public record PartnerReference(String identifier, String nickname) { +public record PartnerReference( + String identifier, + String nickname, + + @Column(name = "properties", columnDefinition = "TEXT") + @Convert(converter = HashMapConverter.class) + Map properties +) { + public PartnerReference { + // Canonical constructor - initialize properties if null + if (properties == null) { + properties = new HashMap<>(); + } + } + + // Convenience constructor for backward compatibility + public PartnerReference(String identifier, String nickname) { + this(identifier, nickname, new HashMap<>()); + } } diff --git a/src/main/java/com/metaformsystems/redline/domain/service/TenantService.java b/src/main/java/com/metaformsystems/redline/domain/service/TenantService.java index f5df3ca..ef8933d 100644 --- a/src/main/java/com/metaformsystems/redline/domain/service/TenantService.java +++ b/src/main/java/com/metaformsystems/redline/domain/service/TenantService.java @@ -16,6 +16,7 @@ import com.metaformsystems.redline.api.dto.request.DataPlaneRegistrationRequest; import com.metaformsystems.redline.api.dto.request.ParticipantDeployment; +import com.metaformsystems.redline.api.dto.request.PartnerReferenceRequest; import com.metaformsystems.redline.api.dto.request.TenantRegistration; import com.metaformsystems.redline.api.dto.response.Dataspace; import com.metaformsystems.redline.api.dto.response.Participant; @@ -218,13 +219,49 @@ public Participant getParticipant(Long id) { return toParticipantResource(profile); } + @Transactional + public PartnerReference createPartnerReference(Long providerId, Long tenantId, Long participantId, Long dataspaceId, PartnerReferenceRequest request) { + // Find participant first + var participant = participantRepository.findById(participantId) + .orElseThrow(() -> new ObjectNotFoundException("Participant not found with id: " + participantId)); + + // Verify participant belongs to the specified tenant + if (participant.getTenant() == null || !participant.getTenant().getId().equals(tenantId)) { + throw new ObjectNotFoundException("Participant " + participantId + " does not belong to tenant " + tenantId); + } + + // Verify tenant belongs to the specified service provider + var tenant = participant.getTenant(); + if (tenant.getServiceProvider() == null || !tenant.getServiceProvider().getId().equals(providerId)) { + throw new ObjectNotFoundException("Tenant " + tenantId + " does not belong to service provider " + providerId); + } + + // Find dataspace info + var dataspaceInfo = participant.getDataspaceInfos().stream() + .filter(i -> i.getDataspaceId().equals(dataspaceId)) + .findFirst() + .orElseThrow(() -> new ObjectNotFoundException("Dataspace info not found for participant " + participantId + " and dataspace " + dataspaceId)); + + // Create and add partner reference + var partnerReference = new com.metaformsystems.redline.domain.entity.PartnerReference( + request.identifier(), + request.nickname(), + request.properties() != null ? request.properties() : new java.util.HashMap<>() + ); + + dataspaceInfo.getPartners().add(partnerReference); + participantRepository.save(participant); + + return new PartnerReference(partnerReference.identifier(), partnerReference.nickname(), partnerReference.properties()); + } + @Transactional public List getPartnerReferences(Long participantId, Long dataspacesId) { return participantRepository.findById(participantId).stream() .flatMap(p -> p.getDataspaceInfos().stream()) .filter(i -> i.getDataspaceId().equals(dataspacesId)) .flatMap(i -> i.getPartners().stream()) - .map(r -> new PartnerReference(r.identifier(), r.nickname())) + .map(r -> new PartnerReference(r.identifier(), r.nickname(), r.properties())) .toList(); } diff --git a/src/test/java/com/metaformsystems/redline/api/controller/TenantControllerIntegrationTest.java b/src/test/java/com/metaformsystems/redline/api/controller/TenantControllerIntegrationTest.java index 0dc9697..a69ef99 100644 --- a/src/test/java/com/metaformsystems/redline/api/controller/TenantControllerIntegrationTest.java +++ b/src/test/java/com/metaformsystems/redline/api/controller/TenantControllerIntegrationTest.java @@ -17,6 +17,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.metaformsystems.redline.api.dto.request.DataspaceInfo; import com.metaformsystems.redline.api.dto.request.ParticipantDeployment; +import com.metaformsystems.redline.api.dto.request.PartnerReferenceRequest; import com.metaformsystems.redline.api.dto.request.ServiceProvider; import com.metaformsystems.redline.api.dto.request.TenantRegistration; import com.metaformsystems.redline.application.service.TokenProvider; @@ -480,6 +481,100 @@ void shouldGetParticipantDataspaces_withMultipleDataspaces() throws Exception { .andExpect(jsonPath("$[*].name").value(org.hamcrest.Matchers.containsInAnyOrder("Test Dataspace", "Second Dataspace"))); } + @Test + void shouldCreatePartnerReference() throws Exception { + // Create a tenant and participant with dataspace info + var tenant = new Tenant(); + tenant.setName("Test Tenant"); + tenant.setServiceProvider(serviceProvider); + tenant = tenantRepository.save(tenant); + + var participant = new Participant(); + participant.setIdentifier("Test Participant"); + participant.setTenant(tenant); + + // Add dataspace info to participant + var dataspaceInfo = new com.metaformsystems.redline.domain.entity.DataspaceInfo(); + dataspaceInfo.setDataspaceId(dataspace.getId()); + participant.getDataspaceInfos().add(dataspaceInfo); + + tenant.addParticipant(participant); + participant = participantRepository.save(participant); + + var request = new PartnerReferenceRequest("did:web:partner.com", "Partner Name", Map.of("key", "value")); + + mockMvc.perform(post("/api/ui/service-providers/{providerId}/tenants/{tenantId}/participants/{participantId}/partners/{dataspaceId}", + serviceProvider.getId(), tenant.getId(), participant.getId(), dataspace.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.identifier").value("did:web:partner.com")) + .andExpect(jsonPath("$.nickname").value("Partner Name")) + .andExpect(jsonPath("$.properties.key").value("value")); + + // Verify partner was saved + var savedParticipant = participantRepository.findById(participant.getId()).orElseThrow(); + var savedDataspaceInfo = savedParticipant.getDataspaceInfos().iterator().next(); + assertThat(savedDataspaceInfo.getPartners()).hasSize(1); + assertThat(savedDataspaceInfo.getPartners().get(0).identifier()).isEqualTo("did:web:partner.com"); + assertThat(savedDataspaceInfo.getPartners().get(0).nickname()).isEqualTo("Partner Name"); + } + + @Test + void shouldCreatePartnerReference_withProperties() throws Exception { + // Create a tenant and participant with dataspace info + var tenant = new Tenant(); + tenant.setName("Test Tenant"); + tenant.setServiceProvider(serviceProvider); + tenant = tenantRepository.save(tenant); + + var participant = new Participant(); + participant.setIdentifier("Test Participant"); + participant.setTenant(tenant); + + var dataspaceInfo = new com.metaformsystems.redline.domain.entity.DataspaceInfo(); + dataspaceInfo.setDataspaceId(dataspace.getId()); + participant.getDataspaceInfos().add(dataspaceInfo); + + tenant.addParticipant(participant); + participant = participantRepository.save(participant); + + var properties = Map.of( + "region", "EU", + "compliance", "GDPR", + "metadata", Map.of("createdBy", "admin", "tags", List.of("partner", "trusted")) + ); + var request = new PartnerReferenceRequest("did:web:partner.com", "Partner Name", properties); + + mockMvc.perform(post("/api/ui/service-providers/{providerId}/tenants/{tenantId}/participants/{participantId}/partners/{dataspaceId}", + serviceProvider.getId(), tenant.getId(), participant.getId(), dataspace.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.identifier").value("did:web:partner.com")) + .andExpect(jsonPath("$.nickname").value("Partner Name")) + .andExpect(jsonPath("$.properties.region").value("EU")) + .andExpect(jsonPath("$.properties.compliance").value("GDPR")); + } + + @Test + void shouldNotCreatePartnerReference_whenParticipantNotFound() throws Exception { + var tenant = new Tenant(); + tenant.setName("Test Tenant"); + tenant.setServiceProvider(serviceProvider); + tenant = tenantRepository.save(tenant); + + var request = new PartnerReferenceRequest("did:web:partner.com", "Partner Name"); + + mockMvc.perform(post("/api/ui/service-providers/{providerId}/tenants/{tenantId}/participants/{participantId}/partners/{dataspaceId}", + serviceProvider.getId(), tenant.getId(), 999L, dataspace.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(404)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Participant not found"))); + } + @Test void shouldGetParticipantDataspaces_whenParticipantNotFound() throws Exception { // Create a tenant without participant diff --git a/src/test/java/com/metaformsystems/redline/domain/service/TenantServiceIntegrationTest.java b/src/test/java/com/metaformsystems/redline/domain/service/TenantServiceIntegrationTest.java index b9f211d..6152fbc 100644 --- a/src/test/java/com/metaformsystems/redline/domain/service/TenantServiceIntegrationTest.java +++ b/src/test/java/com/metaformsystems/redline/domain/service/TenantServiceIntegrationTest.java @@ -16,6 +16,7 @@ import com.metaformsystems.redline.api.dto.request.DataspaceInfo; import com.metaformsystems.redline.api.dto.request.ParticipantDeployment; +import com.metaformsystems.redline.api.dto.request.PartnerReferenceRequest; import com.metaformsystems.redline.api.dto.request.TenantRegistration; import com.metaformsystems.redline.api.dto.response.VirtualParticipantAgent; import com.metaformsystems.redline.application.service.TokenProvider; @@ -455,8 +456,40 @@ void shouldGetPartnerReferences() { var participant = participantRepository.findById(participantId).orElseThrow(); var dataspaceInfo = participant.getDataspaceInfos().iterator().next(); var references = new ArrayList(); + var partner1Properties = Map.of("key1", "value1", "key2", 123); + var partner2Properties = Map.of("key3", "value3"); + references.add(new PartnerReference("did:web:partner1.com", "Partner One", partner1Properties)); + references.add(new PartnerReference("did:web:partner2.com", "Partner Two", partner2Properties)); + dataspaceInfo.setPartners(references); + participantRepository.save(participant); + + + var result = tenantService.getPartnerReferences(participantId, dataspace.getId()); + + + assertThat(result).hasSize(2); + assertThat(result).anyMatch(ref -> ref.identifier().equals("did:web:partner1.com") + && ref.nickname().equals("Partner One") + && ref.properties().equals(partner1Properties)); + assertThat(result).anyMatch(ref -> ref.identifier().equals("did:web:partner2.com") + && ref.nickname().equals("Partner Two") + && ref.properties().equals(partner2Properties)); + } + + @Test + void shouldGetPartnerReferences_withEmptyProperties() { + + var infos = List.of(new DataspaceInfo(dataspace.getId(), List.of(), List.of(), Map.of())); + var registration = new TenantRegistration("Test Tenant", infos); + var tenant = tenantService.registerTenant(serviceProvider.getId(), registration); + var participantId = tenant.participants().iterator().next().id(); + + // Add partners to the participant's dataspace info with empty properties + var participant = participantRepository.findById(participantId).orElseThrow(); + var dataspaceInfo = participant.getDataspaceInfos().iterator().next(); + var references = new ArrayList(); references.add(new PartnerReference("did:web:partner1.com", "Partner One")); - references.add(new PartnerReference("did:web:partner2.com", "Partner Two")); + references.add(new PartnerReference("did:web:partner2.com", "Partner Two", Map.of())); dataspaceInfo.setPartners(references); participantRepository.save(participant); @@ -465,8 +498,14 @@ void shouldGetPartnerReferences() { assertThat(result).hasSize(2); - assertThat(result).anyMatch(ref -> ref.identifier().equals("did:web:partner1.com") && ref.nickname().equals("Partner One")); - assertThat(result).anyMatch(ref -> ref.identifier().equals("did:web:partner2.com") && ref.nickname().equals("Partner Two")); + assertThat(result).anyMatch(ref -> ref.identifier().equals("did:web:partner1.com") + && ref.nickname().equals("Partner One") + && ref.properties() != null + && ref.properties().isEmpty()); + assertThat(result).anyMatch(ref -> ref.identifier().equals("did:web:partner2.com") + && ref.nickname().equals("Partner Two") + && ref.properties() != null + && ref.properties().isEmpty()); } @Test @@ -549,6 +588,145 @@ void shouldGetParticipantDataspaces_whenNoDataspaces() { assertThat(result).isEmpty(); } + @Test + void shouldCreatePartnerReference() { + var infos = List.of(new DataspaceInfo(dataspace.getId(), List.of(), List.of(), Map.of())); + var registration = new TenantRegistration("Test Tenant", infos); + var tenant = tenantService.registerTenant(serviceProvider.getId(), registration); + var participantId = tenant.participants().iterator().next().id(); + + var request = new PartnerReferenceRequest("did:web:partner.com", "Partner Name", Map.of("key", "value")); + + var result = tenantService.createPartnerReference(serviceProvider.getId(), tenant.id(), participantId, dataspace.getId(), request); + + assertThat(result).isNotNull(); + assertThat(result.identifier()).isEqualTo("did:web:partner.com"); + assertThat(result.nickname()).isEqualTo("Partner Name"); + assertThat(result.properties()).containsEntry("key", "value"); + + // Verify partner was saved in database + var participant = participantRepository.findById(participantId).orElseThrow(); + var dataspaceInfo = participant.getDataspaceInfos().iterator().next(); + assertThat(dataspaceInfo.getPartners()).hasSize(1); + assertThat(dataspaceInfo.getPartners().get(0).identifier()).isEqualTo("did:web:partner.com"); + assertThat(dataspaceInfo.getPartners().get(0).nickname()).isEqualTo("Partner Name"); + } + + @Test + void shouldCreatePartnerReference_withProperties() { + var infos = List.of(new DataspaceInfo(dataspace.getId(), List.of(), List.of(), Map.of())); + var registration = new TenantRegistration("Test Tenant", infos); + var tenant = tenantService.registerTenant(serviceProvider.getId(), registration); + var participantId = tenant.participants().iterator().next().id(); + + var properties = Map.of( + "region", "EU", + "compliance", "GDPR", + "active", true + ); + var request = new PartnerReferenceRequest("did:web:partner.com", "Partner Name", properties); + + var result = tenantService.createPartnerReference(serviceProvider.getId(), tenant.id(), participantId, dataspace.getId(), request); + + assertThat(result).isNotNull(); + assertThat(result.properties()).containsEntry("region", "EU"); + assertThat(result.properties()).containsEntry("compliance", "GDPR"); + assertThat(result.properties()).containsEntry("active", true); + } + + @Test + void shouldCreatePartnerReference_withoutProperties() { + var infos = List.of(new DataspaceInfo(dataspace.getId(), List.of(), List.of(), Map.of())); + var registration = new TenantRegistration("Test Tenant", infos); + var tenant = tenantService.registerTenant(serviceProvider.getId(), registration); + var participantId = tenant.participants().iterator().next().id(); + + var request = new PartnerReferenceRequest("did:web:partner.com", "Partner Name"); + + var result = tenantService.createPartnerReference(serviceProvider.getId(), tenant.id(), participantId, dataspace.getId(), request); + + assertThat(result).isNotNull(); + assertThat(result.identifier()).isEqualTo("did:web:partner.com"); + assertThat(result.nickname()).isEqualTo("Partner Name"); + assertThat(result.properties()).isNotNull(); + assertThat(result.properties()).isEmpty(); + } + + @Test + void shouldNotCreatePartnerReference_whenParticipantNotFound() { + var infos = List.of(new DataspaceInfo(dataspace.getId(), List.of(), List.of(), Map.of())); + var registration = new TenantRegistration("Test Tenant", infos); + var tenant = tenantService.registerTenant(serviceProvider.getId(), registration); + + var request = new PartnerReferenceRequest("did:web:partner.com", "Partner Name"); + + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> + tenantService.createPartnerReference(serviceProvider.getId(), tenant.id(), 999L, dataspace.getId(), request))) + .isInstanceOf(ObjectNotFoundException.class) + .hasMessageContaining("Participant not found with id: 999"); + } + + @Test + void shouldNotCreatePartnerReference_whenParticipantDoesNotBelongToTenant() { + var infos1 = List.of(new DataspaceInfo(dataspace.getId(), List.of(), List.of(), Map.of())); + var registration1 = new TenantRegistration("Tenant One", infos1); + var tenant1 = tenantService.registerTenant(serviceProvider.getId(), registration1); + var participantId = tenant1.participants().iterator().next().id(); + + var infos2 = List.of(new DataspaceInfo(dataspace.getId(), List.of(), List.of(), Map.of())); + var registration2 = new TenantRegistration("Tenant Two", infos2); + var tenant2 = tenantService.registerTenant(serviceProvider.getId(), registration2); + + var request = new PartnerReferenceRequest("did:web:partner.com", "Partner Name"); + + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> + tenantService.createPartnerReference(serviceProvider.getId(), tenant2.id(), participantId, dataspace.getId(), request))) + .isInstanceOf(ObjectNotFoundException.class) + .hasMessageContaining("does not belong to tenant"); + } + + @Test + void shouldNotCreatePartnerReference_whenTenantDoesNotBelongToProvider() { + // Create another service provider + var otherProvider = new ServiceProvider(); + otherProvider.setName("Other Provider"); + otherProvider = serviceProviderRepository.save(otherProvider); + + var infos = List.of(new DataspaceInfo(dataspace.getId(), List.of(), List.of(), Map.of())); + var registration = new TenantRegistration("Test Tenant", infos); + var tenant = tenantService.registerTenant(otherProvider.getId(), registration); + var participantId = tenant.participants().iterator().next().id(); + + var request = new PartnerReferenceRequest("did:web:partner.com", "Partner Name"); + + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> + tenantService.createPartnerReference(serviceProvider.getId(), tenant.id(), participantId, dataspace.getId(), request))) + .isInstanceOf(ObjectNotFoundException.class) + .hasMessageContaining("does not belong to service provider"); + } + + @Test + void shouldNotCreatePartnerReference_whenDataspaceInfoNotFound() { + final var tenant = new Tenant(); + tenant.setName("Test Tenant"); + tenant.setServiceProvider(serviceProvider); + final var savedTenant = tenantRepository.save(tenant); + + final var participant = new Participant(); + participant.setIdentifier("Test Participant"); + participant.setTenant(savedTenant); + // No dataspace info added + savedTenant.addParticipant(participant); + final var savedParticipant = participantRepository.save(participant); + + var request = new PartnerReferenceRequest("did:web:partner.com", "Partner Name"); + + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> + tenantService.createPartnerReference(serviceProvider.getId(), savedTenant.getId(), savedParticipant.getId(), dataspace.getId(), request))) + .isInstanceOf(ObjectNotFoundException.class) + .hasMessageContaining("Dataspace info not found"); + } + @Test void shouldGetParticipantDataspaces_whenParticipantNotFound() { // Test with non-existent participant ID