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.
*
*
* - Alphanumeric characters only.
@@ -53,7 +58,7 @@ public class UidUtils {
*
- First character is alphabetic.
*
*
- * @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);
+ }
}