Skip to content
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<groupId>org.hisp</groupId>
<artifactId>dhis2-java-client</artifactId>
<version>2.1.8</version>
<version>2.1.9-SNAPSHOT</version>
<packaging>jar</packaging>

<name>DHIS 2 API client for Java</name>
Expand Down
76 changes: 73 additions & 3 deletions src/main/java/org/hisp/dhis/util/UidUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -45,15 +50,15 @@ 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.
*
* <ul>
* <li>Alphanumeric characters only.
* <li>Exactly 11 characters long.
* <li>First character is alphabetic.
* </ul>
*
* @return a UID string.
* @return a DHIS2 UID string.
*/
public static String generateUid() {
return generateCode(UID_LENGTH);
Expand Down Expand Up @@ -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();
}
}
43 changes: 43 additions & 0 deletions src/test/java/org/hisp/dhis/util/UidUtilsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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!OHIK1KdaGeXGUt97&#1U4hTAE*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);
}
}