From b30c350e0a11ae8f6b37ef701bb1b3f9f86f6bb4 Mon Sep 17 00:00:00 2001 From: Christophe Loiseau <116@lab0.net> Date: Fri, 16 Feb 2024 15:23:32 +0100 Subject: [PATCH] feat: stable contract offer id in DspCatalogService (#795) --- CHANGELOG.md | 1 + .../ContractAgreementPageCardBuilder.java | 2 - .../catalog/mapper/DspContractOfferUtils.java | 78 +++++++++++++++++++ .../catalog/mapper/DspDataOfferBuilder.java | 6 +- .../utils/catalog/DspCatalogServiceTest.java | 5 +- .../mapper/DspContractOfferUtilsTest.java | 24 ++++++ .../edc/utils/catalog/catalogResponse.json | 2 +- 7 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 utils/catalog-parser/src/main/java/de/sovity/edc/utils/catalog/mapper/DspContractOfferUtils.java create mode 100644 utils/catalog-parser/src/test/java/de/sovity/edc/utils/catalog/mapper/DspContractOfferUtilsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 70472d5d8..ee9470b6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ please see [changelog_updates.md](docs/dev/changelog_updates.md). - Add new MDS fields and migrate existing MDS asset keys to mobilityDCAT-AP #### Patch Changes +- DspCatalogService: Contract Offer IDs are now stable ### Deployment Migration Notes - new logging-house-client env for mds: diff --git a/extensions/wrapper/wrapper/src/main/java/de/sovity/edc/ext/wrapper/api/ui/pages/contract_agreements/services/ContractAgreementPageCardBuilder.java b/extensions/wrapper/wrapper/src/main/java/de/sovity/edc/ext/wrapper/api/ui/pages/contract_agreements/services/ContractAgreementPageCardBuilder.java index bc8fa989b..d090c4401 100644 --- a/extensions/wrapper/wrapper/src/main/java/de/sovity/edc/ext/wrapper/api/ui/pages/contract_agreements/services/ContractAgreementPageCardBuilder.java +++ b/extensions/wrapper/wrapper/src/main/java/de/sovity/edc/ext/wrapper/api/ui/pages/contract_agreements/services/ContractAgreementPageCardBuilder.java @@ -22,7 +22,6 @@ import de.sovity.edc.ext.wrapper.api.ui.pages.transferhistory.TransferProcessStateService; import lombok.NonNull; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.eclipse.edc.connector.contract.spi.types.agreement.ContractAgreement; import org.eclipse.edc.connector.contract.spi.types.negotiation.ContractNegotiation; import org.eclipse.edc.connector.transfer.spi.types.TransferProcess; @@ -35,7 +34,6 @@ import static de.sovity.edc.ext.wrapper.utils.EdcDateUtils.utcMillisToOffsetDateTime; import static de.sovity.edc.ext.wrapper.utils.EdcDateUtils.utcSecondsToOffsetDateTime; -@Slf4j @RequiredArgsConstructor public class ContractAgreementPageCardBuilder { private final PolicyMapper policyMapper; diff --git a/utils/catalog-parser/src/main/java/de/sovity/edc/utils/catalog/mapper/DspContractOfferUtils.java b/utils/catalog-parser/src/main/java/de/sovity/edc/utils/catalog/mapper/DspContractOfferUtils.java new file mode 100644 index 000000000..e7cbc18a3 --- /dev/null +++ b/utils/catalog-parser/src/main/java/de/sovity/edc/utils/catalog/mapper/DspContractOfferUtils.java @@ -0,0 +1,78 @@ +package de.sovity.edc.utils.catalog.mapper; + +import de.sovity.edc.utils.JsonUtils; +import de.sovity.edc.utils.jsonld.JsonLdUtils; +import de.sovity.edc.utils.jsonld.vocab.Prop; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import lombok.val; +import org.eclipse.edc.connector.contract.spi.ContractId; +import org.jetbrains.annotations.NotNull; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +public class DspContractOfferUtils { + + /** + * /!\ Workaround + *
+ * The Eclipse EDC uses a new random UUID for each policy that it returns and in turn a new contract ID. + * This Eclipse ID can't be used as such. + * As a workaround, we must introduce our own ID. + * For a first iteration, we will assume that the content of the policy remains the same (same content, same order) + * and hash it to use it as a key. + * + * @param contract The contract to compute an ID from + * @return A base64 string that can be used as an id for the {@code contract} + */ + public static String buildStableId(JsonObject contract) { + // FIXME: This doesn't enforce any property order and may cause trouble if the returned policy schema is not consistent. + // Use canonical form if needed later. + val noId = Json.createObjectBuilder(contract).remove(Prop.ID).build(); + val policyId = hash(noId); + + val currentId = ContractId.parseId(JsonLdUtils.string(contract, Prop.ID)) + .orElseThrow((failure) -> { + throw new RuntimeException("Failed to parse the contract id: " + failure.getFailureDetail()); + }); + + return currentId.definitionPart() + ":" + currentId.assetIdPart() + ":" + policyId; + } + + @NotNull + private static String hash(JsonObject noId) { + val policyJsonString = JsonUtils.toJson(noId); + val sha1 = sha1(policyJsonString); + // encoding with base16 to make the hash readable to humans (similarly to how the random UUID would have been readable) + val base16 = toBase16(sha1); + return toBase64(base16); + } + + @NotNull + private static String toBase64(String string) { + byte[] stringBytes = string.getBytes(StandardCharsets.UTF_8); + byte[] bytes = Base64.getEncoder().encode(stringBytes); + return new String(bytes); + } + + @NotNull + private static String toBase16(byte[] bytes) { + val sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(Character.forDigit(b >> 4 & 0xf, 16)); + sb.append(Character.forDigit(b & 0xf, 16)); + } + return sb.toString(); + } + + private static byte[] sha1(String string) { + try { + return MessageDigest.getInstance("sha-1").digest(string.getBytes()); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } +} diff --git a/utils/catalog-parser/src/main/java/de/sovity/edc/utils/catalog/mapper/DspDataOfferBuilder.java b/utils/catalog-parser/src/main/java/de/sovity/edc/utils/catalog/mapper/DspDataOfferBuilder.java index 119d9d102..f5ef31736 100644 --- a/utils/catalog-parser/src/main/java/de/sovity/edc/utils/catalog/mapper/DspDataOfferBuilder.java +++ b/utils/catalog-parser/src/main/java/de/sovity/edc/utils/catalog/mapper/DspDataOfferBuilder.java @@ -22,6 +22,7 @@ import jakarta.json.Json; import jakarta.json.JsonObject; import lombok.RequiredArgsConstructor; +import lombok.val; import org.eclipse.edc.jsonld.spi.JsonLd; import org.jetbrains.annotations.NotNull; @@ -66,6 +67,9 @@ private DspDataOffer buildDataOffer(JsonObject dataset) { @NotNull private DspContractOffer buildContractOffer(JsonObject json) { - return new DspContractOffer(JsonLdUtils.id(json), json); + val stableId = DspContractOfferUtils.buildStableId(json); + val withStableId = Json.createObjectBuilder(json).remove(Prop.ID).add(Prop.ID, stableId).build(); + + return new DspContractOffer(stableId, withStableId); } } diff --git a/utils/catalog-parser/src/test/java/de/sovity/edc/utils/catalog/DspCatalogServiceTest.java b/utils/catalog-parser/src/test/java/de/sovity/edc/utils/catalog/DspCatalogServiceTest.java index 1124f8405..4a34cb5a1 100644 --- a/utils/catalog-parser/src/test/java/de/sovity/edc/utils/catalog/DspCatalogServiceTest.java +++ b/utils/catalog-parser/src/test/java/de/sovity/edc/utils/catalog/DspCatalogServiceTest.java @@ -45,7 +45,8 @@ private DspCatalogService newDspCatalogService(String resultJsonFilename) { var result = CompletableFuture.completedFuture(StatusResult.success(catalogJson.getBytes(StandardCharsets.UTF_8))); when(catalogService.requestCatalog(eq(endpoint), eq("dataspace-protocol-http"), eq(QuerySpec.max()))).thenReturn(result); - var dataOfferBuilder = new DspDataOfferBuilder(new TitaniumJsonLd(mock(Monitor.class))); + var monitor = mock(Monitor.class); + var dataOfferBuilder = new DspDataOfferBuilder(new TitaniumJsonLd(monitor)); return new DspCatalogService(catalogService, dataOfferBuilder); } @@ -71,7 +72,7 @@ void testCatalogMapping() { assertThat(offer.getContractOffers()).hasSize(1); var co = offer.getContractOffers().get(0); - assertThat(co.getContractOfferId()).isEqualTo("policy-1"); + assertThat(co.getContractOfferId()).isEqualTo("contract-id:asset-id:ODJjMDNjMmM4YzQ5NmE0OTg4ZjVjOTY4OGU2MmMyN2UxYjIxZDAwZQ=="); assertThat(toJson(co.getPolicyJsonLd())).contains("ALWAYS_TRUE"); assertThat(offer.getDistributions()).hasSize(1); diff --git a/utils/catalog-parser/src/test/java/de/sovity/edc/utils/catalog/mapper/DspContractOfferUtilsTest.java b/utils/catalog-parser/src/test/java/de/sovity/edc/utils/catalog/mapper/DspContractOfferUtilsTest.java new file mode 100644 index 000000000..21ceeed8d --- /dev/null +++ b/utils/catalog-parser/src/test/java/de/sovity/edc/utils/catalog/mapper/DspContractOfferUtilsTest.java @@ -0,0 +1,24 @@ +package de.sovity.edc.utils.catalog.mapper; + +import jakarta.json.Json; +import lombok.val; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class DspContractOfferUtilsTest { + @Test + void testCanConvertTheRandomIdToStableId() { + // arrange + val contractOffer = Json.createObjectBuilder() + .add("@id", "part1:part2:part3") + .add("somefield", "somevalue") + .build(); + + // act + val result = DspContractOfferUtils.buildStableId(contractOffer); + + // assert + assertThat(result).isEqualTo("part1:part2:MjliNzcwMjdjMzA2YzE3ZGYyZWNhZjY2OGI3OTYxY2U5OTY1YmExNw=="); + } +} diff --git a/utils/catalog-parser/src/test/resources/de/sovity/edc/utils/catalog/catalogResponse.json b/utils/catalog-parser/src/test/resources/de/sovity/edc/utils/catalog/catalogResponse.json index d069f7302..613b7ac49 100644 --- a/utils/catalog-parser/src/test/resources/de/sovity/edc/utils/catalog/catalogResponse.json +++ b/utils/catalog-parser/src/test/resources/de/sovity/edc/utils/catalog/catalogResponse.json @@ -5,7 +5,7 @@ "@id": "test-1.0", "@type": "dcat:Dataset", "odrl:hasPolicy": { - "@id": "policy-1", + "@id": "contract-id:asset-id:policy-id", "@type": "odrl:Set", "odrl:permission": { "odrl:target": "test-1.0",