diff --git a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java index 36af911ca..3dd46b1b1 100644 --- a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java +++ b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java @@ -15,10 +15,10 @@ package org.eclipse.edc.identityhub.did; import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.iam.did.spi.document.Service; import org.eclipse.edc.identithub.did.spi.DidDocumentPublisherRegistry; import org.eclipse.edc.identithub.did.spi.DidDocumentService; import org.eclipse.edc.identithub.did.spi.model.DidResource; -import org.eclipse.edc.identithub.did.spi.model.DidState; import org.eclipse.edc.identithub.did.spi.store.DidResourceStore; import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.result.ServiceResult; @@ -42,20 +42,6 @@ public DidDocumentServiceImpl(TransactionContext transactionContext, DidResource this.registry = registry; } - @Override - public ServiceResult store(DidDocument document) { - return transactionContext.execute(() -> { - var res = DidResource.Builder.newInstance() - .document(document) - .did(document.getId()) - .build(); - var result = didResourceStore.save(res); - return result.succeeded() ? - ServiceResult.success() : - ServiceResult.fromFailure(result); - }); - } - @Override public ServiceResult publish(String did) { return transactionContext.execute(() -> { @@ -94,59 +80,77 @@ public ServiceResult unpublish(String did) { }); } + @Override - public ServiceResult update(DidDocument document) { + public ServiceResult> queryDocuments(QuerySpec query) { return transactionContext.execute(() -> { - // obtain existing resource from storage - var did = document.getId(); - var existing = didResourceStore.findById(did); - if (existing == null) { - return ServiceResult.notFound(notFoundMessage(did)); - } - - //update only the did document - var updatedResource = DidResource.Builder.newInstance() - .document(document) - .did(did) - .state(existing.getState()) - .createTimestamp(existing.getCreateTimestamp()) - .stateTimeStamp(existing.getStateTimestamp()) - .build(); - - var res = didResourceStore.update(updatedResource); - return res.succeeded() ? - ServiceResult.success() : - ServiceResult.fromFailure(res); + var res = didResourceStore.query(query); + return ServiceResult.success(res.stream().map(DidResource::getDocument).toList()); }); } @Override - public ServiceResult deleteById(String did) { + public DidResource findById(String did) { + return transactionContext.execute(() -> didResourceStore.findById(did)); + } + + @Override + public ServiceResult addService(String did, Service service) { return transactionContext.execute(() -> { - var existing = didResourceStore.findById(did); - if (existing == null) { - return ServiceResult.notFound(notFoundMessage(did)); + var didResource = didResourceStore.findById(did); + if (didResource == null) { + return ServiceResult.notFound("DID '%s' not found.".formatted(did)); } - if (existing.getState() == DidState.PUBLISHED.code()) { - return ServiceResult.conflict("Cannot delete DID '%s' because it is already published. Un-publish first!".formatted(did)); + var services = didResource.getDocument().getService(); + if (services.stream().anyMatch(s -> s.getId().equals(service.getId()))) { + return ServiceResult.conflict("DID '%s' already contains a service endpoint with ID '%s'.".formatted(did, service.getId())); } - var res = didResourceStore.deleteById(did); - return res.succeeded() ? + services.add(service); + var updateResult = didResourceStore.update(didResource); + return updateResult.succeeded() ? ServiceResult.success() : - ServiceResult.fromFailure(res); + ServiceResult.fromFailure(updateResult); + }); } @Override - public ServiceResult> queryDocuments(QuerySpec query) { + public ServiceResult replaceService(String did, Service service) { return transactionContext.execute(() -> { - var res = didResourceStore.query(query); - return ServiceResult.success(res.stream().map(DidResource::getDocument).toList()); + var didResource = didResourceStore.findById(did); + if (didResource == null) { + return ServiceResult.notFound("DID '%s' not found.".formatted(did)); + } + var services = didResource.getDocument().getService(); + if (services.stream().noneMatch(s -> s.getId().equals(service.getId()))) { + return ServiceResult.badRequest("DID '%s' does not contain a service endpoint with ID '%s'.".formatted(did, service.getId())); + } + services.add(service); + var updateResult = didResourceStore.update(didResource); + return updateResult.succeeded() ? + ServiceResult.success() : + ServiceResult.fromFailure(updateResult); + }); } @Override - public DidResource findById(String did) { - return transactionContext.execute(() -> didResourceStore.findById(did)); + public ServiceResult removeService(String did, String serviceId) { + return transactionContext.execute(() -> { + var didResource = didResourceStore.findById(did); + if (didResource == null) { + return ServiceResult.notFound("DID '%s' not found.".formatted(did)); + } + var services = didResource.getDocument().getService(); + var hasRemoved = services.removeIf(s -> s.getId().equals(serviceId)); + if (!hasRemoved) { + return ServiceResult.badRequest("DID '%s' does not contain a service endpoint with ID '%s'.".formatted(did, serviceId)); + } + var updateResult = didResourceStore.update(didResource); + return updateResult.succeeded() ? + ServiceResult.success() : + ServiceResult.fromFailure(updateResult); + + }); } } diff --git a/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java b/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java index 4b2440aa3..2089fafeb 100644 --- a/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java +++ b/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java @@ -33,12 +33,10 @@ import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @@ -56,22 +54,6 @@ void setUp() { service = new DidDocumentServiceImpl(trx, storeMock, publisherRegistry); } - @Test - void store() { - var doc = createDidDocument().build(); - when(storeMock.save(argThat(dr -> dr.getDocument().equals(doc)))).thenReturn(StoreResult.success()); - assertThat(service.store(doc)).isSucceeded(); - } - - @Test - void store_alreadyExists() { - var doc = createDidDocument().build(); - when(storeMock.save(argThat(dr -> dr.getDocument().equals(doc)))).thenReturn(StoreResult.alreadyExists("foo")); - assertThat(service.store(doc)).isFailed().detail().isEqualTo("foo"); - verify(storeMock).save(any()); - verifyNoInteractions(publisherMock); - } - @Test void publish() { var doc = createDidDocument().build(); @@ -187,90 +169,152 @@ void unpublish_publisherReportsError() { } @Test - void update() { + void queryDocuments() { + var q = QuerySpec.max(); var doc = createDidDocument().build(); - var did = doc.getId(); - when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).state(DidState.PUBLISHED).document(doc).build()); - when(storeMock.update(any())).thenReturn(StoreResult.success()); + var res = DidResource.Builder.newInstance().did(doc.getId()).state(DidState.PUBLISHED).document(doc).build(); + when(storeMock.query(any())).thenReturn(List.of(res)); - assertThat(service.update(doc)).isSucceeded(); + assertThat(service.queryDocuments(q)).isSucceeded(); - verify(storeMock).findById(did); - verify(storeMock).update(argThat(dr -> dr.getDocument().equals(doc))); + verify(storeMock).query(eq(q)); verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); } @Test - void update_notExists() { + void addEndpoint() { var doc = createDidDocument().build(); var did = doc.getId(); - when(storeMock.findById(eq(did))).thenReturn(null); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); when(storeMock.update(any())).thenReturn(StoreResult.success()); + var res = service.addService(did, new Service("new-id", "test-type", "https://test.com")); + assertThat(res).isSucceeded(); - assertThat(service.update(doc)) - .isFailed() + verify(storeMock).findById(eq(did)); + verify(storeMock).update(any()); + verifyNoMoreInteractions(storeMock, publisherMock); + } + + @Test + void addEndpoint_alreadyExists() { + var newService = new Service("new-id", "test-type", "https://test.com"); + var doc = createDidDocument().service(List.of(newService)).build(); + var did = doc.getId(); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); + var res = service.addService(did, newService); + assertThat(res).isFailed() .detail() - .isEqualTo(service.notFoundMessage(did)); + .isEqualTo("DID 'did:web:testdid' already contains a service endpoint with ID 'new-id'."); - verify(storeMock).findById(did); - verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + verify(storeMock).findById(eq(did)); + verifyNoMoreInteractions(storeMock, publisherMock); } @Test - void deleteById() { + void addEndpoint_didNotFound() { var doc = createDidDocument().build(); var did = doc.getId(); - when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).state(DidState.UNPUBLISHED).document(doc).build()); - when(storeMock.deleteById(any())).thenReturn(StoreResult.success()); + when(storeMock.findById(eq(did))).thenReturn(null); + var res = service.addService(did, new Service("test-id", "test-type", "https://test.com")); + assertThat(res).isFailed() + .detail() + .isEqualTo("DID 'did:web:testdid' not found."); - assertThat(service.deleteById(did)).isSucceeded(); + verify(storeMock).findById(eq(did)); + verifyNoMoreInteractions(storeMock, publisherMock); + } - verify(storeMock).findById(did); - verify(storeMock).deleteById(did); - verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + @Test + void replaceEndpoint() { + var toReplace = new Service("new-id", "test-type", "https://test.com"); + var doc = createDidDocument().service(List.of(toReplace)).build(); + var did = doc.getId(); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); + when(storeMock.update(any())).thenReturn(StoreResult.success()); + + var res = service.replaceService(did, toReplace); + assertThat(res).isSucceeded(); + + verify(storeMock).findById(eq(did)); + verify(storeMock).update(any()); + verifyNoMoreInteractions(storeMock, publisherMock); } @Test - void deleteById_alreadyPublished() { + void replaceEndpoint_doesNotExist() { + var replace = new Service("new-id", "test-type", "https://test.com"); var doc = createDidDocument().build(); var did = doc.getId(); - when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).state(DidState.PUBLISHED).document(doc).build()); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); - assertThat(service.deleteById(did)).isFailed() + var res = service.replaceService(did, replace); + assertThat(res).isFailed() .detail() - .isEqualTo("Cannot delete DID '%s' because it is already published. Un-publish first!".formatted(did)); + .isEqualTo("DID 'did:web:testdid' does not contain a service endpoint with ID 'new-id'."); - verify(storeMock).findById(did); - verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + verify(storeMock).findById(eq(did)); + verifyNoMoreInteractions(storeMock, publisherMock); } @Test - void deleteById_notExists() { + void replaceEndpoint_didNotFound() { var doc = createDidDocument().build(); var did = doc.getId(); - when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).state(DidState.UNPUBLISHED).document(doc).build()); - when(storeMock.deleteById(any())).thenReturn(StoreResult.notFound("test-message")); + when(storeMock.findById(eq(did))).thenReturn(null); + var res = service.replaceService(did, new Service("test-id", "test-type", "https://test.com")); + assertThat(res).isFailed() + .detail() + .isEqualTo("DID 'did:web:testdid' not found."); + + verify(storeMock).findById(eq(did)); + verifyNoMoreInteractions(storeMock, publisherMock); + } + + @Test + void removeEndpoint() { + var toRemove = new Service("new-id", "test-type", "https://test.com"); + var doc = createDidDocument().service(List.of(toRemove)).build(); + var did = doc.getId(); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); + when(storeMock.update(any())).thenReturn(StoreResult.success()); - assertThat(service.deleteById(did)).isFailed().detail().isEqualTo("test-message"); + var res = service.removeService(did, toRemove.getId()); + assertThat(res).isSucceeded(); - verify(storeMock).findById(did); - verify(storeMock).deleteById(did); - verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + verify(storeMock).findById(eq(did)); + verify(storeMock).update(any()); + verifyNoMoreInteractions(storeMock, publisherMock); } @Test - void queryDocuments() { - var q = QuerySpec.max(); + void removeEndpoint_doesNotExist() { var doc = createDidDocument().build(); - var res = DidResource.Builder.newInstance().did(doc.getId()).state(DidState.PUBLISHED).document(doc).build(); - when(storeMock.query(any())).thenReturn(List.of(res)); + var did = doc.getId(); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); - assertThat(service.queryDocuments(q)).isSucceeded(); + var res = service.removeService(did, "not-exist-id"); + assertThat(res).isFailed() + .detail().isEqualTo("DID 'did:web:testdid' does not contain a service endpoint with ID 'not-exist-id'."); - verify(storeMock).query(eq(q)); - verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + verify(storeMock).findById(eq(did)); + verifyNoMoreInteractions(storeMock, publisherMock); } + @Test + void removeEndpoint_didNotFound() { + var doc = createDidDocument().build(); + var did = doc.getId(); + when(storeMock.findById(eq(did))).thenReturn(null); + var res = service.removeService(did, "does-not-matter-id"); + assertThat(res).isFailed() + .detail() + .isEqualTo("DID 'did:web:testdid' not found."); + + verify(storeMock).findById(eq(did)); + verifyNoMoreInteractions(storeMock, publisherMock); + } + + private DidDocument.Builder createDidDocument() { return DidDocument.Builder.newInstance() .id("did:web:testdid") diff --git a/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApi.java b/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApi.java index 0a6730de1..ef9beb13d 100644 --- a/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApi.java +++ b/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApi.java @@ -26,6 +26,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.iam.did.spi.document.Service; import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.web.spi.ApiErrorDetail; @@ -35,22 +36,6 @@ info = @Info(description = "This is the Management API for DID documents", title = "DID Management API", version = "1")) public interface DidManagementApi { - @Tag(name = "DID Management API") - @Operation(description = "Stores a new DID document and optionally also publishes it", - requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = DidDocument.class), mediaType = "application/json")), - parameters = {@Parameter(name = "publish", description = "Indicates whether the DID should be published right after creation")}, - responses = { - @ApiResponse(responseCode = "200", description = "The DID document was successfully stored"), - @ApiResponse(responseCode = "400", description = "Request body was malformed, for example the DID document was invalid", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), - @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), - @ApiResponse(responseCode = "409", description = "Can't create the DID document, because a document with the same ID already exists", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")) - } - ) - void createDidDocument(DidDocument document, boolean publish); - @Tag(name = "DID Management API") @Operation(description = "Publish an (existing) DID document. The DID is expected to exist in the database.", requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = DidRequestPayload.class), mediaType = "application/json")), @@ -80,57 +65,78 @@ public interface DidManagementApi { void unpublishDidFromBody(DidRequestPayload didRequestPayload); @Tag(name = "DID Management API") - @Operation(description = "Updates an (existing) DID document and re-publishes it if so desired. The DID is expected to exist in the database.", - parameters = {@Parameter(name = "republish", description = "Indicates whether the DID document should be re-published after the update.")}, + @Operation(description = "Query for DID documents..", + requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = QuerySpec.class), mediaType = "application/json")), responses = { - @ApiResponse(responseCode = "200", description = "The DID document was successfully updated."), + @ApiResponse(responseCode = "200", description = "The DID document was successfully deleted.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = DidDocument.class)))), @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), - @ApiResponse(responseCode = "404", description = "The DID could not be updated because it does not exist.", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")) + @ApiResponse(responseCode = "400", description = "The query was malformed or was not understood by the server.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), } ) - void updateDid(DidDocument document, boolean republish); + Collection queryDid(QuerySpec querySpec); @Tag(name = "DID Management API") - @Operation(description = "Delete an (existing) DID document. The DID is expected to exist in the database.", + @Operation(description = "Get state of a DID document", requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = DidRequestPayload.class), mediaType = "application/json")), responses = { - @ApiResponse(responseCode = "200", description = "The DID document was successfully deleted."), + @ApiResponse(responseCode = "200", description = "The DID state was successfully obtained"), + @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "The DID does not exist.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + } + ) + String getState(DidRequestPayload request); + + @Tag(name = "DID Management API") + @Operation(description = "Adds a service endpoint to a particular DID document.", + requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = Service.class), mediaType = "application/json")), + parameters = {@Parameter(name = "autoPublish", description = "Whether the DID should get republished after the removal. Defaults to false.")}, + responses = { + @ApiResponse(responseCode = "200", description = "The DID document was successfully updated."), @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), - @ApiResponse(responseCode = "404", description = "The DID could not be deleted because it does not exist.", + @ApiResponse(responseCode = "409", description = "The DID document could not be updated, because a service endpoint with the same ID already exists.", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), - @ApiResponse(responseCode = "409", description = "The DID could not be deleted because it is already published. Un-publish first.", + @ApiResponse(responseCode = "404", description = "The service endpoint could not be added because the DID does not exist.", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")) } ) - void deleteDidFromBody(DidRequestPayload request); + void addEndpoint(String did, Service service, boolean autoPublish); @Tag(name = "DID Management API") - @Operation(description = "Query for DID documents..", - requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = QuerySpec.class), mediaType = "application/json")), + @Operation(description = "Replaces a service endpoint of a particular DID document.", + requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = Service.class), mediaType = "application/json")), + parameters = {@Parameter(name = "autoPublish", description = "Whether the DID should get republished after the removal. Defaults to false.")}, responses = { - @ApiResponse(responseCode = "200", description = "The DID document was successfully deleted.", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = DidDocument.class)))), + @ApiResponse(responseCode = "200", description = "The DID document was successfully updated."), @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), - @ApiResponse(responseCode = "400", description = "The query was malformed or was not understood by the server.", + @ApiResponse(responseCode = "400", description = "The DID document could not be updated, because a service endpoint with the given ID does not exist.", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "The service endpoint could not be replaced because the DID does not exist.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")) } ) - Collection queryDid(QuerySpec querySpec); + void replaceEndpoint(String did, Service service, boolean autoPublish); @Tag(name = "DID Management API") - @Operation(description = "Get state of a DID document", - requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = DidRequestPayload.class), mediaType = "application/json")), + @Operation(description = "Removes a service endpoint from a particular DID document.", + requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = Service.class), mediaType = "application/json")), + parameters = {@Parameter(name = "serviceId", description = "The ID of the service that should get removed"), @Parameter(name = "autoPublish", description = "Whether the DID should " + + "get republished after the removal. Defaults to false.")}, responses = { - @ApiResponse(responseCode = "200", description = "The DID state was successfully obtained"), + @ApiResponse(responseCode = "200", description = "The DID document was successfully updated."), @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), - @ApiResponse(responseCode = "404", description = "The DID does not exist.", + @ApiResponse(responseCode = "400", description = "The DID document could not be updated, because a service endpoint with the same ID already exists.", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "The service endpoint could not be added because the DID does not exist.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")) } ) - String getState(DidRequestPayload request); + void removeEndpoint(String did, String serviceId, boolean autoPublish); } diff --git a/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiController.java b/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiController.java index 9445fde29..210b055b8 100644 --- a/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiController.java +++ b/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiController.java @@ -15,18 +15,19 @@ package org.eclipse.edc.identityhub.api.didmanagement.v1; import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.PATCH; import jakarta.ws.rs.POST; -import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.iam.did.spi.document.Service; import org.eclipse.edc.identithub.did.spi.DidDocumentService; import org.eclipse.edc.identithub.did.spi.model.DidState; -import org.eclipse.edc.identityhub.api.didmanagement.v1.validation.DidRequestValidator; import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.result.ServiceResult; -import org.eclipse.edc.web.spi.exception.ValidationFailureException; import java.util.Collection; @@ -39,22 +40,11 @@ public class DidManagementApiController implements DidManagementApi { private final DidDocumentService documentService; - private final DidRequestValidator requestValidator; public DidManagementApiController(DidDocumentService documentService) { this.documentService = documentService; - this.requestValidator = new DidRequestValidator(); } - @POST - @Override - public void createDidDocument(DidDocument document, @QueryParam("publish") boolean publish) { - requestValidator.validate(document).orElseThrow(ValidationFailureException::new); - - documentService.store(document) - .compose(v -> publish ? documentService.publish(document.getId()) : ServiceResult.success()) - .orElseThrow(exceptionMapper(DidDocument.class, document.getId())); - } @Override @POST @@ -72,23 +62,6 @@ public void unpublishDidFromBody(DidRequestPayload didRequestPayload) { .orElseThrow(exceptionMapper(DidDocument.class, didRequestPayload.did())); } - @PUT - @Override - public void updateDid(DidDocument document, @QueryParam("republish") boolean republish) { - requestValidator.validate(document).orElseThrow(ValidationFailureException::new); - var did = document.getId(); - documentService.update(document) - .compose(v -> republish ? documentService.publish(did) : ServiceResult.success()) - .orElseThrow(exceptionMapper(DidDocument.class, did)); - } - - @Override - @POST - @Path("/delete") - public void deleteDidFromBody(DidRequestPayload request) { - documentService.deleteById(request.did()) - .orElseThrow(exceptionMapper(DidDocument.class, request.did())); - } @POST @Path("/query") @@ -106,4 +79,31 @@ public String getState(DidRequestPayload request) { return byId != null ? DidState.from(byId.getState()).toString() : null; } + @Override + @POST + @Path("/{did}/endpoints") + public void addEndpoint(@PathParam("did") String did, Service service, @QueryParam("autoPublish") boolean autoPublish) { + documentService.addService(did, service) + .compose(v -> autoPublish ? documentService.publish(did) : ServiceResult.success()) + .orElseThrow(exceptionMapper(Service.class, did)); + } + + @Override + @PATCH + @Path("/{did}/endpoints") + public void replaceEndpoint(@PathParam("did") String did, Service service, @QueryParam("autoPublish") boolean autoPublish) { + documentService.replaceService(did, service) + .compose(v -> autoPublish ? documentService.publish(did) : ServiceResult.success()) + .orElseThrow(exceptionMapper(Service.class, did)); + } + + @Override + @DELETE + @Path("/{did}/endpoints") + public void removeEndpoint(@PathParam("did") String did, @QueryParam("serviceId") String serviceId, @QueryParam("autoPublish") boolean autoPublish) { + documentService.removeService(did, serviceId) + .compose(v -> autoPublish ? documentService.publish(did) : ServiceResult.success()) + .orElseThrow(exceptionMapper(Service.class, did)); + } + } diff --git a/extensions/did/did-management-api/src/test/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiControllerTest.java b/extensions/did/did-management-api/src/test/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiControllerTest.java index 2dc77185f..f33676084 100644 --- a/extensions/did/did-management-api/src/test/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiControllerTest.java +++ b/extensions/did/did-management-api/src/test/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiControllerTest.java @@ -34,7 +34,7 @@ import static org.hamcrest.Matchers.anyOf; import static org.hamcrest.Matchers.equalTo; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -47,45 +47,6 @@ class DidManagementApiControllerTest extends RestControllerTestBase { public static final String TEST_DID = "did:web:host%3A1234:test-did"; private final DidDocumentService didDocumentServiceMock = mock(); - @Test - void create_success() { - when(didDocumentServiceMock.store(any())).thenReturn(ServiceResult.success()); - var document = createDidDocument().build(); - - baseRequest() - .with() - .body(document) - .post() - .then() - .log().ifError() - .statusCode(anyOf(equalTo(200), equalTo(204))); - } - - @Test - void create_alreadyExists_expect409() { - when(didDocumentServiceMock.store(any())).thenReturn(ServiceResult.conflict("already exists")); - var document = createDidDocument().build(); - - baseRequest() - .body(document) - .post() - .then() - .log().ifValidationFails() - .statusCode(409); - } - - @Test - void create_malformedBody_expect400() { - when(didDocumentServiceMock.store(any())).thenReturn(ServiceResult.success()); - var document = createDidDocument().id("not a uri").build(); - - baseRequest() - .body(document) - .post() - .then() - .log().ifValidationFails() - .statusCode(400); - } @Test void publish_success() { @@ -185,147 +146,244 @@ void unpublish_whenNotSupported_expect400() { } @Test - void updateDid_success() { - var doc = createDidDocument().id(TEST_DID).build(); - when(didDocumentServiceMock.update(any())).thenReturn(ServiceResult.success()); + void query_withSimpleField() { + var resultList = List.of(createDidDocument().build()); + when(didDocumentServiceMock.queryDocuments(any())).thenReturn(ServiceResult.success(resultList)); + var q = QuerySpec.Builder.newInstance().filter(new Criterion("id", "=", "foobar")).build(); - baseRequest() - .body(doc) - .put() + var docListType = new TypeRef>() { + }; + var docList = baseRequest() + .body(q) + .post("/query") .then() .log().ifError() - .statusCode(204); - verify(didDocumentServiceMock).update(argThat(dd -> dd.getId().equals(TEST_DID))); + .statusCode(200) + .extract().body().as(docListType); + + assertThat(docList).isNotEmpty().hasSize(1) + .usingRecursiveFieldByFieldElementComparator() + .isEqualTo(resultList); + verify(didDocumentServiceMock).queryDocuments(eq(q)); + } + + @Test + void query_invalidQuery_expect400() { + when(didDocumentServiceMock.queryDocuments(any())).thenReturn(ServiceResult.badRequest("test-message")); + var q = QuerySpec.Builder.newInstance().build(); + baseRequest() + .body(q) + .post("/query") + .then() + .log().ifValidationFails() + .statusCode(400); + + verify(didDocumentServiceMock).queryDocuments(eq(q)); + } + + @Test + void addEndpoint() { + when(didDocumentServiceMock.addService(eq(TEST_DID), any(Service.class))).thenReturn(ServiceResult.success()); + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .post("/%s/endpoints".formatted(TEST_DID)) + .then() + .log().ifValidationFails() + .statusCode(anyOf(equalTo(200), equalTo(204))); + verify(didDocumentServiceMock).addService(eq(TEST_DID), any(Service.class)); verifyNoMoreInteractions(didDocumentServiceMock); } @Test - void updateDid_success_withRepublish() { - var doc = createDidDocument().id(TEST_DID).build(); - when(didDocumentServiceMock.update(any())).thenReturn(ServiceResult.success()); + void addEndpoint_withAutoPublish() { + when(didDocumentServiceMock.addService(eq(TEST_DID), any(Service.class))).thenReturn(ServiceResult.success()); when(didDocumentServiceMock.publish(eq(TEST_DID))).thenReturn(ServiceResult.success()); - baseRequest() - .body(doc) - .put("?republish=true") + .body(new DidRequestPayload(TEST_DID)) + .post("/%s/endpoints?autoPublish=true".formatted(TEST_DID)) .then() - .log().ifError() - .statusCode(204); - verify(didDocumentServiceMock).update(argThat(dd -> dd.getId().equals(TEST_DID))); + .log().ifValidationFails() + .statusCode(anyOf(equalTo(200), equalTo(204))); + verify(didDocumentServiceMock).addService(eq(TEST_DID), any(Service.class)); verify(didDocumentServiceMock).publish(eq(TEST_DID)); verifyNoMoreInteractions(didDocumentServiceMock); } @Test - void updateDid_success_withRepublishFails() { - - var doc = createDidDocument().id(TEST_DID).build(); - when(didDocumentServiceMock.update(any())).thenReturn(ServiceResult.success()); - when(didDocumentServiceMock.publish(eq(TEST_DID))).thenReturn(ServiceResult.badRequest("test-failure")); - + void addEndpoint_whenAutoPublishFails_expect400() { + when(didDocumentServiceMock.addService(eq(TEST_DID), any(Service.class))).thenReturn(ServiceResult.success()); + when(didDocumentServiceMock.publish(eq(TEST_DID))).thenReturn(ServiceResult.badRequest("publisher not working")); baseRequest() - .body(doc) - .put("?republish=true") + .body(new DidRequestPayload(TEST_DID)) + .post("/%s/endpoints?autoPublish=true".formatted(TEST_DID)) .then() - .log().ifError() + .log().ifValidationFails() .statusCode(400); - verify(didDocumentServiceMock).update(argThat(dd -> dd.getId().equals(TEST_DID))); + verify(didDocumentServiceMock).addService(eq(TEST_DID), any(Service.class)); verify(didDocumentServiceMock).publish(eq(TEST_DID)); verifyNoMoreInteractions(didDocumentServiceMock); } @Test - void updateDid_whenNotExist_expect404() { - - var doc = createDidDocument().id(TEST_DID).build(); - when(didDocumentServiceMock.update(any())).thenReturn(ServiceResult.notFound("test-failure")); + void addEndpoint_alreadyExists() { + when(didDocumentServiceMock.addService(eq(TEST_DID), any(Service.class))).thenReturn(ServiceResult.conflict("exists")); + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .post("/%s/endpoints".formatted(TEST_DID)) + .then() + .log().ifValidationFails() + .statusCode(409); + verify(didDocumentServiceMock).addService(eq(TEST_DID), any(Service.class)); + } + @Test + void addEndpoint_didNotFound() { + when(didDocumentServiceMock.addService(eq(TEST_DID), any(Service.class))).thenReturn(ServiceResult.notFound("did not found")); baseRequest() - .body(doc) - .put() + .body(new DidRequestPayload(TEST_DID)) + .post("/%s/endpoints".formatted(TEST_DID)) .then() - .log().ifError() + .log().ifValidationFails() .statusCode(404); - verify(didDocumentServiceMock).update(argThat(dd -> dd.getId().equals(TEST_DID))); + verify(didDocumentServiceMock).addService(eq(TEST_DID), any(Service.class)); + } + + @Test + void replaceEndpoint() { + when(didDocumentServiceMock.replaceService(eq(TEST_DID), any(Service.class))).thenReturn(ServiceResult.success()); + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .patch("/%s/endpoints".formatted(TEST_DID)) + .then() + .log().ifValidationFails() + .statusCode(anyOf(equalTo(200), equalTo(204))); + verify(didDocumentServiceMock).replaceService(eq(TEST_DID), any(Service.class)); verifyNoMoreInteractions(didDocumentServiceMock); } @Test - void deleteDid_success() { + void replaceEndpoint_withAutoPublish() { + when(didDocumentServiceMock.replaceService(eq(TEST_DID), any(Service.class))).thenReturn(ServiceResult.success()); + when(didDocumentServiceMock.publish(eq(TEST_DID))).thenReturn(ServiceResult.success()); - when(didDocumentServiceMock.deleteById(eq(TEST_DID))).thenReturn(ServiceResult.success()); baseRequest() .body(new DidRequestPayload(TEST_DID)) - .post("/delete") + .patch("/%s/endpoints?autoPublish=true".formatted(TEST_DID)) .then() - .log().ifError() - .statusCode(204); - verify(didDocumentServiceMock).deleteById(eq(TEST_DID)); + .log().ifValidationFails() + .statusCode(anyOf(equalTo(200), equalTo(204))); + verify(didDocumentServiceMock).replaceService(eq(TEST_DID), any(Service.class)); + verify(didDocumentServiceMock).publish(eq(TEST_DID)); verifyNoMoreInteractions(didDocumentServiceMock); } @Test - void deleteDid_whenNotExist_expect404() { + void replaceEndpoint_whenAutoPublishFails_expect400() { + when(didDocumentServiceMock.replaceService(eq(TEST_DID), any(Service.class))).thenReturn(ServiceResult.success()); + when(didDocumentServiceMock.publish(eq(TEST_DID))).thenReturn(ServiceResult.badRequest("publisher not working")); - when(didDocumentServiceMock.deleteById(eq(TEST_DID))).thenReturn(ServiceResult.notFound("test-message")); baseRequest() .body(new DidRequestPayload(TEST_DID)) - .post("/delete") + .patch("/%s/endpoints?autoPublish=true".formatted(TEST_DID)) .then() - .log().ifError() - .statusCode(404); - verify(didDocumentServiceMock).deleteById(eq(TEST_DID)); + .log().ifValidationFails() + .statusCode(400); + verify(didDocumentServiceMock).replaceService(eq(TEST_DID), any(Service.class)); + verify(didDocumentServiceMock).publish(eq(TEST_DID)); verifyNoMoreInteractions(didDocumentServiceMock); } @Test - void deleteDid_whenAlreadyPublished_expect409() { + void replaceEndpoint_doesNotExist() { + when(didDocumentServiceMock.replaceService(eq(TEST_DID), any(Service.class))).thenReturn(ServiceResult.badRequest("service not found")); + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .patch("/%s/endpoints".formatted(TEST_DID)) + .then() + .log().ifValidationFails() + .statusCode(400); + verify(didDocumentServiceMock).replaceService(eq(TEST_DID), any(Service.class)); + } + + @Test + void replaceEndpoint_didNotFound() { + when(didDocumentServiceMock.replaceService(eq(TEST_DID), any(Service.class))).thenReturn(ServiceResult.notFound("did not found")); + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .patch("/%s/endpoints".formatted(TEST_DID)) + .then() + .log().ifValidationFails() + .statusCode(404); + verify(didDocumentServiceMock).replaceService(eq(TEST_DID), any(Service.class)); + } - when(didDocumentServiceMock.deleteById(eq(TEST_DID))).thenReturn(ServiceResult.conflict("test-message")); + @Test + void removeEndpoint() { + when(didDocumentServiceMock.removeService(eq(TEST_DID), anyString())).thenReturn(ServiceResult.success()); baseRequest() .body(new DidRequestPayload(TEST_DID)) - .post("/delete") + .delete("/%s/endpoints?serviceId=test-service-id".formatted(TEST_DID)) .then() - .log().ifError() - .statusCode(409); - verify(didDocumentServiceMock).deleteById(eq(TEST_DID)); + .log().ifValidationFails() + .statusCode(anyOf(equalTo(200), equalTo(204))); + verify(didDocumentServiceMock).removeService(eq(TEST_DID), eq("test-service-id")); verifyNoMoreInteractions(didDocumentServiceMock); } @Test - void query_withSimpleField() { - var resultList = List.of(createDidDocument().build()); - when(didDocumentServiceMock.queryDocuments(any())).thenReturn(ServiceResult.success(resultList)); - var q = QuerySpec.Builder.newInstance().filter(new Criterion("id", "=", "foobar")).build(); + void removeEndpoint_withAutoPublish() { + when(didDocumentServiceMock.removeService(eq(TEST_DID), anyString())).thenReturn(ServiceResult.success()); + when(didDocumentServiceMock.publish(eq(TEST_DID))).thenReturn(ServiceResult.success()); + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .delete("/%s/endpoints?serviceId=test-service-id&autoPublish=true".formatted(TEST_DID)) + .then() + .log().ifValidationFails() + .statusCode(anyOf(equalTo(200), equalTo(204))); + verify(didDocumentServiceMock).removeService(eq(TEST_DID), eq("test-service-id")); + verify(didDocumentServiceMock).publish(eq(TEST_DID)); - var docListType = new TypeRef>() { - }; - var docList = baseRequest() - .body(q) - .post("/query") + verifyNoMoreInteractions(didDocumentServiceMock); + } + + @Test + void removeEndpoint_whenAutoPublishFails_expect400() { + when(didDocumentServiceMock.removeService(eq(TEST_DID), anyString())).thenReturn(ServiceResult.success()); + when(didDocumentServiceMock.publish(eq(TEST_DID))).thenReturn(ServiceResult.badRequest("publisher not reachable")); + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .delete("/%s/endpoints?serviceId=test-service-id&autoPublish=true".formatted(TEST_DID)) .then() - .log().ifError() - .statusCode(200) - .extract().body().as(docListType); + .log().ifValidationFails() + .statusCode(400); + verify(didDocumentServiceMock).removeService(eq(TEST_DID), eq("test-service-id")); + verify(didDocumentServiceMock).publish(eq(TEST_DID)); - assertThat(docList).isNotEmpty().hasSize(1) - .usingRecursiveFieldByFieldElementComparator() - .isEqualTo(resultList); - verify(didDocumentServiceMock).queryDocuments(eq(q)); + verifyNoMoreInteractions(didDocumentServiceMock); } @Test - void query_invalidQuery_expect400() { - when(didDocumentServiceMock.queryDocuments(any())).thenReturn(ServiceResult.badRequest("test-message")); - var q = QuerySpec.Builder.newInstance().build(); + void removeEndpoint_doesNotExist() { + when(didDocumentServiceMock.removeService(eq(TEST_DID), anyString())).thenReturn(ServiceResult.badRequest("service not found")); baseRequest() - .body(q) - .post("/query") + .body(new DidRequestPayload(TEST_DID)) + .delete("/%s/endpoints?serviceId=test-service-id".formatted(TEST_DID)) .then() .log().ifValidationFails() .statusCode(400); + verify(didDocumentServiceMock).removeService(eq(TEST_DID), eq("test-service-id")); + } - verify(didDocumentServiceMock).queryDocuments(eq(q)); + @Test + void removeEndpoint_didNotFound() { + when(didDocumentServiceMock.removeService(eq(TEST_DID), anyString())).thenReturn(ServiceResult.notFound("did not found")); + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .delete("/%s/endpoints?serviceId=test-service-id".formatted(TEST_DID)) + .then() + .log().ifValidationFails() + .statusCode(404); + verify(didDocumentServiceMock).removeService(eq(TEST_DID), eq("test-service-id")); } @Override diff --git a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentService.java b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentService.java index d63f600bd..08b9c149b 100644 --- a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentService.java +++ b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentService.java @@ -15,6 +15,7 @@ package org.eclipse.edc.identithub.did.spi; import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.iam.did.spi.document.Service; import org.eclipse.edc.identithub.did.spi.model.DidResource; import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.result.ServiceResult; @@ -27,14 +28,6 @@ public interface DidDocumentService { - /** - * Stores a DID document in persistent storage. - * - * @param document the {@link DidDocument} to store - * @return a {@link ServiceResult} to indicate success or failure. - */ - ServiceResult store(DidDocument document); - /** * Publishes an already existing DID document. Returns a failure if the DID document was not found or cannot be published. * @@ -52,21 +45,6 @@ public interface DidDocumentService { */ ServiceResult unpublish(String did); - /** - * Updates a given DID document if it exists, returns a failure otherwise. - * - * @param document The DID document to update - * @return success, or a failure indicating what went wrong. - */ - ServiceResult update(DidDocument document); - - /** - * Deletes a DID document if found. - * - * @param did The ID of the DID document to delete. - * @return A {@link ServiceResult} indicating success or failure. - */ - ServiceResult deleteById(String did); /** * Queries the {@link DidDocument} objects based on the given query specification. @@ -85,4 +63,31 @@ default String noPublisherFoundMessage(String did) { } DidResource findById(String did); + + /** + * Adds a service endpoint entry to a did document. + * + * @param did The DID of the document to which the entry should be added. + * @param service The service endpoint to add. + * @return success if added, a failure otherwise, e.g. because that same service already exists. + */ + ServiceResult addService(String did, Service service); + + /** + * Replaces a service endpoint entry in a did document. + * + * @param did The DID of the document in which the entry should be replaced. + * @param service The new service endpoint . + * @return success if replaced, a failure otherwise, e.g. because a service with that ID does not exist exists. + */ + ServiceResult replaceService(String did, Service service); + + /** + * Removes a service endpoint entry from a did document. + * + * @param did The DID of the document from which the entry should be removed. + * @param serviceId The service endpoint to remove. + * @return success if removed, a failure otherwise, e.g. because a service with that ID does not exist exists. + */ + ServiceResult removeService(String did, String serviceId); }