From 12bf948d858543c5536e2c3577374768d1d26f53 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Thu, 14 Dec 2023 09:10:34 +0100 Subject: [PATCH] feat: implement local DidPublisher --- .../api/PresentationApiExtension.java | 17 +- .../did/DidDocumentPublisherRegistryImpl.java | 44 ++++ .../identityhub/did/DidServicesExtension.java | 12 ++ .../defaults/DidDefaultServicesExtension.java | 5 + .../local-did-publisher/build.gradle.kts | 32 +++ .../publisher/did/local/DidWebController.java | 116 +++++++++++ .../did/local/LocalDidPublisher.java | 103 ++++++++++ .../did/local/LocalDidPublisherExtension.java | 70 +++++++ ...rg.eclipse.edc.spi.system.ServiceExtension | 15 ++ .../did/local/DidWebControllerTest.java | 110 ++++++++++ .../did/local/LocalDidPublisherTest.java | 188 ++++++++++++++++++ .../publisher/did/local/TestFunctions.java | 35 ++++ launcher/build.gradle.kts | 2 + settings.gradle.kts | 1 + .../edc/identithub/did/spi/DidConstants.java | 36 ++++ .../did/spi/DidDocumentPublisher.java | 23 ++- .../did/spi/DidDocumentPublisherRegistry.java | 48 +++++ .../edc/identithub/did/spi/DidWebParser.java | 77 +++++++ .../identithub/did/spi/model/DidResource.java | 4 + .../identithub/did/spi/DidWebParserTest.java | 43 ++++ 20 files changed, 966 insertions(+), 15 deletions(-) create mode 100644 core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentPublisherRegistryImpl.java create mode 100644 extensions/did-publisher/local-did-publisher/build.gradle.kts create mode 100644 extensions/did-publisher/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/DidWebController.java create mode 100644 extensions/did-publisher/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisher.java create mode 100644 extensions/did-publisher/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherExtension.java create mode 100644 extensions/did-publisher/local-did-publisher/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension create mode 100644 extensions/did-publisher/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/DidWebControllerTest.java create mode 100644 extensions/did-publisher/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherTest.java create mode 100644 extensions/did-publisher/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/TestFunctions.java create mode 100644 spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidConstants.java create mode 100644 spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisherRegistry.java create mode 100644 spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidWebParser.java create mode 100644 spi/identity-hub-did-spi/src/test/java/org/eclipse/edc/identithub/did/spi/DidWebParserTest.java diff --git a/core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/PresentationApiExtension.java b/core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/PresentationApiExtension.java index 44c001940..e1736c657 100644 --- a/core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/PresentationApiExtension.java +++ b/core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/PresentationApiExtension.java @@ -34,38 +34,37 @@ import org.eclipse.edc.web.jersey.jsonld.ObjectMapperProvider; import org.eclipse.edc.web.spi.WebService; +import static org.eclipse.edc.identityservice.api.PresentationApiExtension.NAME; import static org.eclipse.edc.spi.CoreConstants.JSON_LD; -@Extension(value = "Presentation API Extension") +@Extension(value = NAME) public class PresentationApiExtension implements ServiceExtension { + public static final String NAME = "Presentation API Extension"; public static final String RESOLUTION_SCOPE = "resolution-scope"; public static final String RESOLUTION_CONTEXT = "resolution"; - @Inject private TypeTransformerRegistry typeTransformer; - @Inject private JsonObjectValidatorRegistry validatorRegistry; - @Inject private WebService webService; - @Inject private AccessTokenVerifier accessTokenVerifier; - @Inject private CredentialQueryResolver credentialResolver; - @Inject private VerifiablePresentationService verifiablePresentationService; - @Inject private JsonLd jsonLd; - @Inject private TypeManager typeManager; + @Override + public String name() { + return NAME; + } + @Override public void initialize(ServiceExtensionContext context) { // setup validator diff --git a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentPublisherRegistryImpl.java b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentPublisherRegistryImpl.java new file mode 100644 index 000000000..219ca79a3 --- /dev/null +++ b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentPublisherRegistryImpl.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 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 org.eclipse.edc.identityhub.did; + +import org.eclipse.edc.identithub.did.spi.DidDocumentPublisher; +import org.eclipse.edc.identithub.did.spi.DidDocumentPublisherRegistry; + +import java.util.HashMap; +import java.util.Map; + +/** + * In-mem variant of the publisher registry. + */ +public class DidDocumentPublisherRegistryImpl implements DidDocumentPublisherRegistry { + private final Map publishers = new HashMap<>(); + + + @Override + public void addPublisher(String didMethodName, DidDocumentPublisher publisher) { + publishers.put(didMethodName, publisher); + } + + @Override + public DidDocumentPublisher getPublisher(String did) { + return publishers.get(did); + } + + @Override + public boolean canPublish(String did) { + return publishers.containsKey(did); + } +} diff --git a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidServicesExtension.java b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidServicesExtension.java index c40f4b6fd..0f30aa2f7 100644 --- a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidServicesExtension.java +++ b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidServicesExtension.java @@ -14,7 +14,9 @@ package org.eclipse.edc.identityhub.did; +import org.eclipse.edc.identithub.did.spi.DidDocumentPublisherRegistry; import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Provider; import org.eclipse.edc.spi.system.ServiceExtension; import static org.eclipse.edc.identityhub.did.DidServicesExtension.NAME; @@ -22,4 +24,14 @@ @Extension(value = NAME) public class DidServicesExtension implements ServiceExtension { public static final String NAME = "DID Service Extension"; + + @Override + public String name() { + return NAME; + } + + @Provider + public DidDocumentPublisherRegistry createRegistry() { + return new DidDocumentPublisherRegistryImpl(); + } } diff --git a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/defaults/DidDefaultServicesExtension.java b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/defaults/DidDefaultServicesExtension.java index d8d31b5c0..c88be87bb 100644 --- a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/defaults/DidDefaultServicesExtension.java +++ b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/defaults/DidDefaultServicesExtension.java @@ -25,6 +25,11 @@ public class DidDefaultServicesExtension implements ServiceExtension { public static final String NAME = "DID Default Services Extension"; + @Override + public String name() { + return NAME; + } + @Provider(isDefault = true) public DidResourceStore createInMemoryDidResourceStore() { return new InMemoryDidResourceStore(); diff --git a/extensions/did-publisher/local-did-publisher/build.gradle.kts b/extensions/did-publisher/local-did-publisher/build.gradle.kts new file mode 100644 index 000000000..5ad383df8 --- /dev/null +++ b/extensions/did-publisher/local-did-publisher/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 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 + * + */ + +plugins { + `java-library` + `java-test-fixtures` + `maven-publish` +} + +val swagger: String by project + +dependencies { + + api(project(":spi:identity-hub-did-spi")) + implementation(libs.jakarta.rsApi) + implementation(libs.edc.spi.web) + + testImplementation(libs.edc.junit) + testImplementation(libs.restAssured) + testImplementation(testFixtures(libs.edc.core.jersey)) +} diff --git a/extensions/did-publisher/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/DidWebController.java b/extensions/did-publisher/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/DidWebController.java new file mode 100644 index 000000000..9d338ba48 --- /dev/null +++ b/extensions/did-publisher/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/DidWebController.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2023 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 org.eclipse.edc.identityhub.publisher.did.local; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.Context; +import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.identithub.did.spi.DidWebParser; +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.EdcException; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.query.Criterion; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.web.spi.exception.InvalidRequestException; + +import java.net.MalformedURLException; +import java.nio.charset.Charset; +import java.util.Optional; +import java.util.regex.Pattern; + +import static jakarta.ws.rs.core.HttpHeaders.CONTENT_TYPE; +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; + +@Consumes(APPLICATION_JSON) +@Produces(APPLICATION_JSON) +@Path("{any:.*}") +public class DidWebController { + private static final Charset DEFAULT_CHARSET = Charset.defaultCharset(); + private static final Pattern CHARSET_REGEX_PATTERN = Pattern.compile("(?i)\\bcharset=\\s*\"?([^\\s;\"]*)"); + private final Monitor monitor; + private final DidResourceStore didResourceStore; + private final DidWebParser didWebParser = new DidWebParser(); + + public DidWebController(Monitor monitor, DidResourceStore didResourceStore) { + this.monitor = monitor; + this.didResourceStore = didResourceStore; + } + + @GET + public DidDocument getDidDocument(@Context ContainerRequestContext context) { + + var httpUrl = context.getUriInfo().getAbsolutePath(); + + var charset = extractCharset(context.getHeaderString(CONTENT_TYPE)); + String did; + try { + did = didWebParser.parse(httpUrl.toURL(), charset); + } catch (MalformedURLException e) { + monitor.warning("Error interpreting request URL", e); + throw new EdcException("Error interpreting request URL", e); + } + + + var q = QuerySpec.Builder.newInstance() + .filter(new Criterion("state", "=", DidState.PUBLISHED.code())) + .filter(new Criterion("did", "=", did)) + .build(); + + monitor.debug("Looking up '%s'".formatted(did)); + var dids = didResourceStore.query(q) + .stream() + .map(DidResource::getDocument) + .toList(); + + if (dids.size() > 1) { + throw new InvalidRequestException("DID '%s' resolved more than one document".formatted(did)); + } + + return dids.stream().findFirst().orElse(null); + } + + private Charset extractCharset(String contentType) { + return Optional.ofNullable(contentType) + .map(ct -> parseCharsetFromContentType(contentType)) + .orElse(DEFAULT_CHARSET); + } + + /** + * Parses the "charset" attribute from the Content-Type header. Returns the value of the charset attribute, + * or {@link Charset#defaultCharset()} if the attribute was not present or represents an invalid charset + * + * @param contentType The Content-Type header + * @return The value of the charset attribute or the default charset + */ + private Charset parseCharsetFromContentType(String contentType) { + var m = CHARSET_REGEX_PATTERN.matcher(contentType); + if (m.find()) { + var cs = m.group(1).trim().toUpperCase(); + if (Charset.isSupported(cs)) { + return Charset.forName(cs); + } else { + monitor.warning("Charset '%s' is not supported, defaulting to %s".formatted(cs, DEFAULT_CHARSET)); + return DEFAULT_CHARSET; + } + } + return DEFAULT_CHARSET; + } +} diff --git a/extensions/did-publisher/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisher.java b/extensions/did-publisher/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisher.java new file mode 100644 index 000000000..d74eb3083 --- /dev/null +++ b/extensions/did-publisher/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisher.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2023 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 org.eclipse.edc.identityhub.publisher.did.local; + +import org.eclipse.edc.identithub.did.spi.DidConstants; +import org.eclipse.edc.identithub.did.spi.DidDocumentPublisher; +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.monitor.Monitor; +import org.eclipse.edc.spi.query.Criterion; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.result.Result; + +import java.util.Collection; + +import static org.eclipse.edc.identithub.did.spi.DidConstants.DID_WEB_METHOD_REGEX; +import static org.eclipse.edc.spi.result.Result.failure; +import static org.eclipse.edc.spi.result.Result.success; + +/** + * A DID publisher that maintains "did:web" documents. All documents in the database ({@link DidResourceStore}) + * where the {@link DidResource#getState()} == {@link DidState#PUBLISHED} are regarded as published and are made available + * through an HTTP endpoint. + */ +public class LocalDidPublisher implements DidDocumentPublisher { + + private final DidResourceStore didResourceStore; + private final Monitor monitor; + + public LocalDidPublisher(DidResourceStore didResourceStore, Monitor monitor) { + this.didResourceStore = didResourceStore; + this.monitor = monitor; + } + + @Override + public boolean canHandle(String id) { + return DID_WEB_METHOD_REGEX.matcher(id).matches(); + } + + @Override + public Result publish(String did) { + var existingDocument = didResourceStore.findById(did); + if (existingDocument == null) { + return Result.failure("A DID Resource with the ID '%s' was not found.".formatted(did)); + } + + if (isPublished(existingDocument)) { + monitor.warning("DID '%s' is already published - this action will overwrite it.".formatted(did)); + } + + existingDocument.transitionState(DidState.PUBLISHED); + + return didResourceStore.update(existingDocument) + .map(v -> success()) + .orElse(f -> failure(f.getFailureDetail())); + } + + @Override + public Result unpublish(String did) { + var existingDocument = didResourceStore.findById(did); + if (existingDocument == null) { + return Result.failure("A DID Resource with the ID '%s' was not found.".formatted(did)); + } + + if (!isPublished(existingDocument)) { + monitor.info("Un-publish DID Resource '%s': not published -> NOOP.".formatted(did)); + // do not return early, the state could be anything + } + + existingDocument.transitionState(DidState.UNPUBLISHED); + return didResourceStore.update(existingDocument) + .map(v -> success()) + .orElse(f -> failure(f.getFailureDetail())); + + } + + @Override + public Collection getPublishedDocuments() { + var q = QuerySpec.Builder.newInstance() + .filter(new Criterion("state", "=", DidState.PUBLISHED.code())) + .filter(new Criterion("did", "like", DidConstants.DID_WEB_METHOD + "%")) + .build(); + + return didResourceStore.query(q); + } + + private boolean isPublished(DidResource didResource) { + return didResource.getState() == DidState.PUBLISHED.code(); + } +} diff --git a/extensions/did-publisher/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherExtension.java b/extensions/did-publisher/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherExtension.java new file mode 100644 index 000000000..74e9fd38b --- /dev/null +++ b/extensions/did-publisher/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherExtension.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2023 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 org.eclipse.edc.identityhub.publisher.did.local; + +import org.eclipse.edc.identithub.did.spi.DidConstants; +import org.eclipse.edc.identithub.did.spi.DidDocumentPublisherRegistry; +import org.eclipse.edc.identithub.did.spi.store.DidResourceStore; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; +import org.eclipse.edc.spi.system.ServiceExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.web.spi.WebServer; +import org.eclipse.edc.web.spi.WebService; +import org.eclipse.edc.web.spi.configuration.WebServiceConfigurer; +import org.eclipse.edc.web.spi.configuration.WebServiceSettings; + +import static org.eclipse.edc.identityhub.publisher.did.local.LocalDidPublisherExtension.NAME; + +@Extension(value = NAME) +public class LocalDidPublisherExtension implements ServiceExtension { + public static final String NAME = "Local DID publisher extension"; + private static final String DID_CONTEXT_ALIAS = "did"; + private static final String DEFAULT_DID_PATH = "/"; + private static final int DEFAULT_DID_PORT = 10100; + public static final WebServiceSettings SETTINGS = WebServiceSettings.Builder.newInstance() + .apiConfigKey("web.http." + DID_CONTEXT_ALIAS) + .contextAlias(DID_CONTEXT_ALIAS) + .defaultPath(DEFAULT_DID_PATH) + .defaultPort(DEFAULT_DID_PORT) + .useDefaultContext(false) + .name("DID:WEB Endpoint API") + .build(); + @Inject + private DidDocumentPublisherRegistry registry; + @Inject + private DidResourceStore didResourceStore; + @Inject + private WebService webService; + @Inject + private WebServiceConfigurer configurator; + @Inject + private WebServer webServer; + + @Override + public String name() { + return NAME; + } + + @Override + public void initialize(ServiceExtensionContext context) { + + var webServiceConfiguration = configurator.configure(context, webServer, SETTINGS); + + var localPublisher = new LocalDidPublisher(didResourceStore, context.getMonitor()); + registry.addPublisher(DidConstants.DID_WEB_METHOD, localPublisher); + webService.registerResource(webServiceConfiguration.getContextAlias(), new DidWebController(context.getMonitor(), didResourceStore)); + } +} diff --git a/extensions/did-publisher/local-did-publisher/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/extensions/did-publisher/local-did-publisher/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension new file mode 100644 index 000000000..633fafab4 --- /dev/null +++ b/extensions/did-publisher/local-did-publisher/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -0,0 +1,15 @@ +# +# Copyright (c) 2023 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 +# +# + +org.eclipse.edc.identityhub.publisher.did.local.LocalDidPublisherExtension \ No newline at end of file diff --git a/extensions/did-publisher/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/DidWebControllerTest.java b/extensions/did-publisher/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/DidWebControllerTest.java new file mode 100644 index 000000000..8370764e7 --- /dev/null +++ b/extensions/did-publisher/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/DidWebControllerTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2023 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 org.eclipse.edc.identityhub.publisher.did.local; + +import io.restassured.specification.RequestSpecification; +import org.eclipse.edc.iam.did.spi.document.DidDocument; +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.junit.annotations.ApiTest; +import org.eclipse.edc.web.jersey.testfixtures.RestControllerTestBase; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.identityhub.publisher.did.local.TestFunctions.createDidResource; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.emptyString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ApiTest +class DidWebControllerTest extends RestControllerTestBase { + + private final DidResourceStore storeMock = mock(); + + @Test + void getDidDocument() { + when(storeMock.query(any())).thenReturn(List.of(publishedDid("did:web:testdid1"))); + + var doc = baseRequest() + .get("/foo/bar") + .then() + .log().ifError() + .statusCode(200) + .extract().body().as(DidDocument.class); + assertThat(doc).isNotNull() + .extracting(DidDocument::getId).isEqualTo("did:web:testdid1"); + } + + @Test + void getDidDocument_multipleDocumentsForDid() { + when(storeMock.query(any())).thenReturn(List.of(publishedDid("did:web:testdid1"), publishedDid("did:web:testdid1"))); + + baseRequest() + .get("/foo/bar") + .then() + .log().ifValidationFails() + .statusCode(400) + .body(containsString("DID '%s' resolved more than one document".formatted("did:web:localhost%%3A%s:foo:bar".formatted(port)))); + } + + @Test + void getDidDocument_withWellKnown() { + when(storeMock.query(any())).thenReturn(List.of(publishedDid("did:web:testdid1"))); + + + baseRequest() + .get("/foo/bar/.well-known/did.json") + .then() + .log().ifValidationFails() + .statusCode(200) + .body(containsString("did:web:testdid1")); + + // verify that the query to the store was issued using the parsed DID + verify(storeMock).query(argThat(qs -> qs.getFilterExpression().stream().anyMatch(c -> c.getOperandRight().equals("did:web:localhost%%3A%s:foo:bar".formatted(port))))); + } + + @Test + void getDidDocument_noResult() { + baseRequest() + .get("/foo/bar") + .then() + .log().ifError() + .statusCode(204) + .body(emptyString()); + } + + @Override + protected Object controller() { + return new DidWebController(monitor, storeMock); + } + + private RequestSpecification baseRequest() { + return given() + .baseUri("http://localhost:" + port) + .when(); + } + + private static DidResource publishedDid(String did) { + return createDidResource(did).state(DidState.PUBLISHED).build(); + } +} \ No newline at end of file diff --git a/extensions/did-publisher/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherTest.java b/extensions/did-publisher/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherTest.java new file mode 100644 index 000000000..ce7809dda --- /dev/null +++ b/extensions/did-publisher/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherTest.java @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2023 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 org.eclipse.edc.identityhub.publisher.did.local; + +import org.eclipse.edc.identithub.did.spi.model.DidState; +import org.eclipse.edc.identithub.did.spi.store.DidResourceStore; +import org.eclipse.edc.junit.assertions.AbstractResultAssert; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.result.StoreResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static java.util.stream.IntStream.range; +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.identityhub.publisher.did.local.TestFunctions.createDidResource; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class LocalDidPublisherTest { + + public static final String DID = "did:web:test"; + private final DidResourceStore storeMock = mock(); + private LocalDidPublisher publisher; + private Monitor monitor; + + @BeforeEach + void setUp() { + monitor = mock(); + publisher = new LocalDidPublisher(storeMock, monitor); + } + + + @ParameterizedTest + @ValueSource(strings = {DID, "DID:web:test", "DID:WEB:TEST"}) + void canHandle(String validDid) { + assertThat(publisher.canHandle(validDid)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"did:web", "DID:web:", "did:indy:whatever", "dod:web:something"}) + void canHandle_invalid(String validDid) { + assertThat(publisher.canHandle(validDid)).isFalse(); + } + + @Test + void publish_success() { + when(storeMock.findById(anyString())).thenReturn(createDidResource().build()); + when(storeMock.update(any())).thenReturn(StoreResult.success()); + + AbstractResultAssert.assertThat(publisher.publish(DID)).isSucceeded(); + + verify(storeMock).findById(anyString()); + verify(storeMock).update(argThat(dr -> dr.getState() == DidState.PUBLISHED.code())); + verifyNoMoreInteractions(storeMock); + } + + @Test + void publish_notExists_returnsFailure() { + when(storeMock.findById(any())).thenReturn(null); + + AbstractResultAssert.assertThat(publisher.publish("did:web:foo")).isFailed() + .detail() + .isEqualTo("A DID Resource with the ID 'did:web:foo' was not found."); + + verify(storeMock).findById(anyString()); + verifyNoMoreInteractions(storeMock); + } + + @Test + void publish_alreadyPublished_expectWarning() { + when(storeMock.findById(anyString())).thenReturn(createDidResource() + .state(DidState.PUBLISHED) + .build()); + when(storeMock.update(any())).thenReturn(StoreResult.success()); + + AbstractResultAssert.assertThat(publisher.publish(DID)).isSucceeded(); + + verify(storeMock).findById(anyString()); + verify(storeMock).update(any()); + verify(monitor).warning("DID 'did:web:test' is already published - this action will overwrite it."); + verifyNoMoreInteractions(storeMock); + } + + @Test + void publish_storeFailsUpdate_returnsFailure() { + when(storeMock.findById(anyString())).thenReturn(createDidResource().build()); + when(storeMock.update(any())).thenReturn(StoreResult.duplicateKeys("test error")); + + AbstractResultAssert.assertThat(publisher.publish(DID)).isFailed() + .detail() + .isEqualTo("test error"); + + verify(storeMock).findById(anyString()); + verify(storeMock).update(any()); + verifyNoMoreInteractions(storeMock); + } + + @Test + void unpublish_success() { + when(storeMock.findById(anyString())).thenReturn(createDidResource() + .state(DidState.PUBLISHED) + .build()); + when(storeMock.update(any())).thenReturn(StoreResult.success()); + + AbstractResultAssert.assertThat(publisher.unpublish(DID)).isSucceeded(); + + verify(storeMock).findById(anyString()); + verify(storeMock).update(argThat(dr -> dr.getState() == DidState.UNPUBLISHED.code())); + verifyNoMoreInteractions(storeMock); + } + + @Test + void unpublish_notExists_returnsFailure() { + when(storeMock.findById(anyString())).thenReturn(null); + + AbstractResultAssert.assertThat(publisher.unpublish(DID)).isFailed() + .detail() + .contains("A DID Resource with the ID 'did:web:test' was not found."); + + verify(storeMock).findById(anyString()); + verifyNoMoreInteractions(storeMock); + } + + @Test + void unpublish_notPublished_expectWarning() { + when(storeMock.findById(anyString())).thenReturn(createDidResource() + .state(DidState.UNPUBLISHED) + .build()); + when(storeMock.update(any())).thenReturn(StoreResult.success()); + + AbstractResultAssert.assertThat(publisher.unpublish(DID)).isSucceeded(); + + verify(storeMock).findById(anyString()); + verify(storeMock).update(any()); + verifyNoMoreInteractions(storeMock); + verify(monitor).info("Un-publish DID Resource 'did:web:test': not published -> NOOP."); + } + + @Test + void unpublish_storeFailsUpdate_returnsFailure() { + when(storeMock.findById(anyString())).thenReturn(createDidResource() + .state(DidState.PUBLISHED) + .build()); + when(storeMock.update(any())).thenReturn(StoreResult.notFound("foobar")); + + AbstractResultAssert.assertThat(publisher.unpublish(DID)).isFailed() + .detail() + .isEqualTo("foobar"); + + verify(storeMock).findById(anyString()); + verify(storeMock).update(any()); + verifyNoMoreInteractions(storeMock); + } + + @Test + void getPublishedDocuments() { + var list = range(0, 10) + .mapToObj(i -> createDidResource().did("did:web:" + i).state(DidState.PUBLISHED).build()) + .toList(); + + when(storeMock.query(any())).thenReturn(list); + + assertThat(publisher.getPublishedDocuments()) + .usingRecursiveFieldByFieldElementComparator() + .containsAll(list); + } + + +} \ No newline at end of file diff --git a/extensions/did-publisher/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/TestFunctions.java b/extensions/did-publisher/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/TestFunctions.java new file mode 100644 index 000000000..194e79de0 --- /dev/null +++ b/extensions/did-publisher/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/TestFunctions.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 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 org.eclipse.edc.identityhub.publisher.did.local; + +import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.identithub.did.spi.model.DidResource; +import org.eclipse.edc.identithub.did.spi.model.DidState; + +public interface TestFunctions { + static DidResource.Builder createDidResource() { + return createDidResource("did:web:test"); + } + + static DidResource.Builder createDidResource(String did) { + return DidResource.Builder.newInstance() + .did(did) + .state(DidState.GENERATED) + .document(DidDocument.Builder.newInstance() + .id(did) + .build()) + .state(DidState.INITIAL); + } +} diff --git a/launcher/build.gradle.kts b/launcher/build.gradle.kts index 963785cdd..db6dcfc66 100644 --- a/launcher/build.gradle.kts +++ b/launcher/build.gradle.kts @@ -20,8 +20,10 @@ plugins { dependencies { runtimeOnly(project(":core:identity-hub-api")) + runtimeOnly(project(":core:identity-hub-did")) runtimeOnly(project(":core:identity-hub-credentials")) runtimeOnly(project(":extensions:cryptography:public-key-provider")) + runtimeOnly(project(":extensions:did-publisher:local-did-publisher")) runtimeOnly(libs.edc.identity.did.core) runtimeOnly(libs.edc.identity.did.web) runtimeOnly(libs.bundles.connector) diff --git a/settings.gradle.kts b/settings.gradle.kts index 8db0a9559..3dc979d87 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,6 +35,7 @@ include(":core:identity-hub-did") // extension modules include(":extensions:cryptography:public-key-provider") include(":extensions:store:sql:identity-hub-did-store-sql") +include(":extensions:did-publisher:local-did-publisher") // other modules include(":launcher") diff --git a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidConstants.java b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidConstants.java new file mode 100644 index 000000000..5b7392175 --- /dev/null +++ b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidConstants.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 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 org.eclipse.edc.identithub.did.spi; + +import java.util.regex.Pattern; + +public interface DidConstants { + /** + * Constant for the DID:WEB method + */ + String DID_WEB_METHOD = "did:web"; + /** + * Pattern for use to parse a DID:WEB identifier + */ + Pattern DID_WEB_METHOD_REGEX = Pattern.compile("(?i)did:web:.+"); + /** + * the /.well-known path extension. Useful when resolving or parsing DIDs. Must be ignored when parsing a URL to a DID. + */ + String WELL_KNOWN = "/.well-known"; + /** + * last path segment when resolving DID documents. Must be clipped off before parsing a URL to a DID + */ + String DID_WEB_DID_DOCUMENT = "did.json"; +} diff --git a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisher.java b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisher.java index 8595d6777..6a6073b6a 100644 --- a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisher.java +++ b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisher.java @@ -15,9 +15,12 @@ package org.eclipse.edc.identithub.did.spi; import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.identithub.did.spi.model.DidResource; import org.eclipse.edc.runtime.metamodel.annotation.ExtensionPoint; import org.eclipse.edc.spi.result.Result; +import java.util.Collection; + /** * The DidDocumentPublisher is responsible for taking a {@link DidDocument} and making it available at a VDR (verifiable data registry). * For example, an implementation may choose to publish the DID to a CDN. @@ -35,18 +38,26 @@ public interface DidDocumentPublisher { boolean canHandle(String id); /** - * Publishes a given {@link DidDocument} to a verifiable data registry (VDR). + * Publishes a given {@link DidDocument} to a verifiable data registry (VDR). Publishing the same DID twice is a noop. * - * @param document the {@link DidDocument} to be published + * @param did the DID to publish. A document with that DID must exist in the database. * @return a {@link Result} object indicating the success or failure of the operation. */ - Result publish(DidDocument document); + Result publish(String did); /** - * Unpublishes a given {@link DidDocument} from a verifiable data registry (VDR). + * Unpublishes a given {@link DidDocument} from a verifiable data registry (VDR). Attempting to unpublish a DID document + * that isn't published will result in an error. * - * @param document the {@link DidDocument} to be unpublished + * @param did the DID to unpublish. A document with that DID must exist in the database. * @return a {@link Result} object indicating the success or failure of the operation. */ - Result unpublish(DidDocument document); + Result unpublish(String did); + + /** + * Returns a list of all {@link DidDocument}s that are managed by this publisher, and are currently published. + * + * @return a list of documents that are currently published. + */ + Collection getPublishedDocuments(); } diff --git a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisherRegistry.java b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisherRegistry.java new file mode 100644 index 000000000..14588a8de --- /dev/null +++ b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisherRegistry.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 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 org.eclipse.edc.identithub.did.spi; + +/** + * Registry that hosts multiple {@link DidDocumentPublisher}s to dispatch the publishing of a DID document based on + * its DID method. + * There can only be one publisher per method. + */ +public interface DidDocumentPublisherRegistry { + + /** + * Registers a {@link DidDocumentPublisher} for a given DID method. + * + * @param didMethodName The DID method name. This may include the "did:" prefix, so both "did:web" and "web" would be valid. + * @param publisher The publisher to register + */ + void addPublisher(String didMethodName, DidDocumentPublisher publisher); + + /** + * Returns the publisher that was registered for a particular DID method. + * + * @param did the DID method for which the publisher was previously registered + * @return A {@link DidDocumentPublisher}, or null if none was registered. + */ + DidDocumentPublisher getPublisher(String did); + + + /** + * Determines whether a given DID can be published by this registry. The DID must conform to the W3C DID Syntax + * + * @param did The W3C DID to examine + * @return true if a publisher is found for this DID method, false if no publisher is found, or the DID is not valid. + */ + boolean canPublish(String did); +} diff --git a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidWebParser.java b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidWebParser.java new file mode 100644 index 000000000..70dc4969e --- /dev/null +++ b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidWebParser.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2023 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 org.eclipse.edc.identithub.did.spi; + +import org.jetbrains.annotations.NotNull; + +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.Charset; + +import static org.eclipse.edc.identithub.did.spi.DidConstants.DID_WEB_DID_DOCUMENT; +import static org.eclipse.edc.identithub.did.spi.DidConstants.WELL_KNOWN; + +/** + * Converts a URL into a did:web identifier by parsing the authority and the path. For example the following conversion applies: + *
+ *     https://foo.bar/some/path/.well-known/did.json -> did:web:foo.bar:some:path
+ *     https://foo.bar/some/path -> did:web:foo.bar:some:path
+ * 
+ */ +public class DidWebParser { + + /** + * Parses a HTTP URL using the specified charset by performing the following steps: + *
    + *
  • strip away any trailing slash from the path
  • + *
  • strip away "did.json" and ".well-known" if present on the path
  • + *
  • replace all remaining slashes with colons ":" in the path
  • + *
  • URL-Encode the authority ("host:port") to clearly distinguish it from the method separator ":"
  • + *
  • prepend "did:web:"
  • + *
+ * + * @param httpUrl The input URL + * @param charset The charset used for encoding the {@link URL#getAuthority()}. Defaults to {@link Charset#defaultCharset()} + * @return a "did:web:XYZ" identifier + */ + public String parse(URL httpUrl, Charset charset) { + var path = httpUrl.getPath(); + path = stripTrailingSlash(path); + + if (path.endsWith(DID_WEB_DID_DOCUMENT)) { + path = path.substring(0, path.indexOf(DID_WEB_DID_DOCUMENT)); + path = stripTrailingSlash(path); + } + if (path.endsWith(WELL_KNOWN)) { + path = path.replace(DidConstants.WELL_KNOWN, ""); + path = stripTrailingSlash(path); + } + path = path.replace("/", ":"); + + // ports must be percent-encoded: + var identifier = "%s%s".formatted(URLEncoder.encode(httpUrl.getAuthority(), charset), path); + + return "%s:%s".formatted(DidConstants.DID_WEB_METHOD, identifier); + } + + @NotNull + private String stripTrailingSlash(String path) { + if (path.endsWith("/")) { + path = path.substring(0, path.lastIndexOf("/")); + } + return path; + } + +} diff --git a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/model/DidResource.java b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/model/DidResource.java index 92efecddc..af7803be7 100644 --- a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/model/DidResource.java +++ b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/model/DidResource.java @@ -61,6 +61,10 @@ public DidDocument getDocument() { return document; } + public void transitionState(DidState newState) { + this.state = newState.code(); + } + public static final class Builder { private final DidResource resource; diff --git a/spi/identity-hub-did-spi/src/test/java/org/eclipse/edc/identithub/did/spi/DidWebParserTest.java b/spi/identity-hub-did-spi/src/test/java/org/eclipse/edc/identithub/did/spi/DidWebParserTest.java new file mode 100644 index 000000000..ce67da356 --- /dev/null +++ b/spi/identity-hub-did-spi/src/test/java/org/eclipse/edc/identithub/did/spi/DidWebParserTest.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 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 org.eclipse.edc.identithub.did.spi; + +import org.junit.jupiter.api.Test; + +import java.net.MalformedURLException; +import java.net.URI; +import java.nio.charset.Charset; + +import static org.assertj.core.api.Assertions.assertThat; + +class DidWebParserTest { + private static final Charset DEFAULT_CHARSET = Charset.defaultCharset(); + private final DidWebParser parser = new DidWebParser(); + + @Test + void parse() throws MalformedURLException { + assertThat(parser.parse(URI.create("http://localhost:123").toURL(), DEFAULT_CHARSET)).isEqualTo("did:web:localhost%3A123"); + assertThat(parser.parse(URI.create("http://localhost:123/.well-known").toURL(), DEFAULT_CHARSET)).isEqualTo("did:web:localhost%3A123"); + assertThat(parser.parse(URI.create("http://localhost:123/asdf/gh").toURL(), DEFAULT_CHARSET)).isEqualTo("did:web:localhost%3A123:asdf:gh"); + assertThat(parser.parse(URI.create("http://localhost:123/asdf/gh/").toURL(), DEFAULT_CHARSET)).isEqualTo("did:web:localhost%3A123:asdf:gh"); + assertThat(parser.parse(URI.create("http://localhost:123/asdf/gh/.well-known").toURL(), DEFAULT_CHARSET)).isEqualTo("did:web:localhost%3A123:asdf:gh"); + assertThat(parser.parse(URI.create("http://localhost:123/asdf/gh/.well-known/").toURL(), DEFAULT_CHARSET)).isEqualTo("did:web:localhost%3A123:asdf:gh"); + assertThat(parser.parse(URI.create("http://localhost:123/asdf/gh/.well-known/did.json").toURL(), DEFAULT_CHARSET)).isEqualTo("did:web:localhost%3A123:asdf:gh"); + assertThat(parser.parse(URI.create("http://localhost:123/asdf/gh/.well-known/did.json/").toURL(), DEFAULT_CHARSET)).isEqualTo("did:web:localhost%3A123:asdf:gh"); + assertThat(parser.parse(URI.create("http://localhost:123/asdf/gh/.well-known/did.json/asdf").toURL(), DEFAULT_CHARSET)).isEqualTo("did:web:localhost%3A123:asdf:gh:.well-known:did.json:asdf"); + assertThat(parser.parse(URI.create("http://localhost:123/asdf/gh/.well-known/did.json/asdf/").toURL(), DEFAULT_CHARSET)).isEqualTo("did:web:localhost%3A123:asdf:gh:.well-known:did.json:asdf"); + + } +} \ No newline at end of file