diff --git a/pom.xml b/pom.xml index f8f60b21..e9710bee 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ org.hisp dhis2-java-client - 2.1.8 + 2.1.9-SNAPSHOT jar DHIS 2 API client for Java diff --git a/src/main/java/org/hisp/dhis/util/UidUtils.java b/src/main/java/org/hisp/dhis/util/UidUtils.java index fd3d1430..f907663a 100644 --- a/src/main/java/org/hisp/dhis/util/UidUtils.java +++ b/src/main/java/org/hisp/dhis/util/UidUtils.java @@ -27,12 +27,17 @@ */ package org.hisp.dhis.util; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.concurrent.ThreadLocalRandom; import java.util.regex.Pattern; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.apache.commons.lang3.StringUtils; -/** Utilities for UID. */ +/** Utilities for DHIS2 UID generation. */ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class UidUtils { private static final String ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; @@ -45,7 +50,7 @@ public class UidUtils { private static final Pattern UID_PATTERN = Pattern.compile("^[a-zA-Z]{1}[a-zA-Z0-9]{10}$"); /** - * Generates a UID according to the following rules. + * Generates a DHIS2 UID according to the following rules. * * * - * @return a UID string. + * @return a DHIS2 UID string. */ public static String generateUid() { return generateCode(UID_LENGTH); @@ -89,4 +94,69 @@ public static String generateCode(int length) { return new String(randomChars); } + + /** + * Generates a DHIS2 UID from an input string. The algorithm is deterministic and minimizes risk + * of collisions. The input must be between 2 and 1024 characters long. + * + * @param input the input string. + * @return a DHIS2 UID. Returns null if the input is invalid, empty string if input is blank. + */ + public static String toUid(String input) { + if (input == null) { + return null; + } + if (input.isBlank()) { + return StringUtils.EMPTY; + } + + if (input.length() < 2 || input.length() > 1024) { + throw new IllegalArgumentException("Input string must be between 3 and 1024 characters long"); + } + + try { + // Hash input string using SHA-256 + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + + // Convert hash to a BigInteger + BigInteger bigInteger = new BigInteger(1, hashBytes); + + // Convert BigInteger to Base62 + String base62 = fromBigInteger(bigInteger, ALPHABET, UID_LENGTH); + + // Ensure the UID starts with a letter + if (Character.isDigit(base62.charAt(0))) { + // If first character is a digit, shift Base62 string by one character by moving first char + // to the end and append 'A' + base62 = base62.substring(1) + ALPHABET.charAt(0); + } + return base62; + + } catch (NoSuchAlgorithmException ex) { + throw new IllegalArgumentException("SHA-256 algorithm not found", ex); + } + } + + /** + * Converts a BigInteger to a Base62 string of a specified length. + * + * @param value the BigInteger to convert. + * @param alphabet the Base62 alphabet. + * @param length the desired length of the Base62 string. + * @return a Base62 string of the specified length. + */ + private static String fromBigInteger(BigInteger value, String alphabet, int length) { + StringBuilder sb = new StringBuilder(); + BigInteger base = BigInteger.valueOf(alphabet.length()); + + for (int i = 0; i < length; i++) { + BigInteger[] qr = value.divideAndRemainder(base); + value = qr[0]; + BigInteger remainder = qr[1]; + sb.insert(0, alphabet.charAt(remainder.intValue())); + } + + return sb.toString(); + } } diff --git a/src/test/java/org/hisp/dhis/util/UidUtilsTest.java b/src/test/java/org/hisp/dhis/util/UidUtilsTest.java index 392214c9..5f249fa3 100644 --- a/src/test/java/org/hisp/dhis/util/UidUtilsTest.java +++ b/src/test/java/org/hisp/dhis/util/UidUtilsTest.java @@ -30,8 +30,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.stream.IntStream; import org.hisp.dhis.support.TestTags; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -56,4 +58,45 @@ void testUidIsValid() { assertFalse(UidUtils.isValidUid("QX4LpiTZmUHg")); assertFalse(UidUtils.isValidUid("1T1hdS_WjfD")); } + + @Test + void testToUid() { + assertToUid("PpZ!m3thN#sm8QVcOdwTcil4"); + assertToUid("5$tiq7K9zMmUX$9VFXaQLFK6d&ShHQUw"); + assertToUid("9ceyjK4b^Xoc0&lKCn0Bqz5xAsYz&$heWypB"); + assertToUid("B5*GfX&Yklr!OHIK1KdaGeXGUt97U4hTAE*bA**ce7@#oO2lB^0Rs9E#G8sJe"); + assertToUid("!OGvawSH8fKIUtIpVl$9^TfMV%V08vHm%uDeT1hnh6d22q7OQSjS7csF05bFRATeUIN&8wX2"); + assertToUid("yjZ2ec#*s9RMpmt^svZN8LyBJUOt&mY8&7nHZ3u%13^ObekBDA!a8ov&enxPE$EuE$GPh1xiy6parm"); + } + + @Test + void testToUidNullAndBlank() { + assertNull(UidUtils.toUid(null)); + assertEquals("", UidUtils.toUid(" ")); + assertEquals("", UidUtils.toUid("")); + } + + @Test + void testToUidDeterminisism() { + String input = UidUtils.toUid("fDv!oHopG7F8asPsvAU8c3MK8$#H7iwW"); + String output = "WpFckPBZBnO"; + + IntStream.range(0, 10) + .forEach( + i -> { + String msg = String.format("Index: %d, input: '%s', output: '%s'", i, input, output); + assertEquals(output, UidUtils.toUid(input), msg); + }); + } + + /** + * Asserts that the method generates a valid UID based on the given identifier. + * + * @param uid + */ + private void assertToUid(String input) { + String output = UidUtils.toUid(input); + String msg = String.format("Output: '%s' not valid for input: '%s'", output, input); + assertTrue(UidUtils.isValidUid(output), msg); + } }