Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
419 changes: 419 additions & 0 deletions core-services/docs/mdms-v2-contract.yml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.egov.user.domain.model;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

/**
* A resolved MDMS {@code MobileNumberValidation} entry — the regex to validate against, and the
* countryCode that entry is configured for (may be null if the entry didn't declare one). Carrying
* both together (rather than just the regex) lets a caller resolving an unspecified countryCode use
* the one MDMS itself configured as the {@code default} entry, instead of falling straight through
* to the application.properties-level fallback.
*
* Getters/setters and a no-args constructor are kept for Jackson (de)serialization when this is
* cached — see {@link org.egov.user.repository.MobileNumerValidationCacheRepository}.
*/
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode
@JsonIgnoreProperties(ignoreUnknown = true)
public class MobileValidationRule {
private String regex;
private String countryCode;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.egov.common.contract.request.RequestInfo;
import org.egov.common.utils.MultiStateInstanceUtil;
import org.egov.tracer.model.CustomException;
import org.egov.user.domain.model.MobileValidationRule;
import org.egov.user.domain.model.mdmsv2.MdmsV2Data;
import org.egov.user.domain.model.mdmsv2.MdmsV2Response;
import org.egov.user.domain.model.mdmsv2.MdmsV2SearchCriteria;
Expand Down Expand Up @@ -94,33 +95,47 @@ public String validateMobileNumberWithCountryCode(String mobileNumber, String co
String stateTenantId = multiStateInstanceUtil.getStateLevelTenant(tenantId);

// 1. Try cache
String regex = cacheRepository.getMobileRegex(stateTenantId, countryCode);
MobileValidationRule rule = cacheRepository.getRule(stateTenantId, countryCode);

if (regex == null) {
if (rule == null) {
// 2. Try incoming tenantId in MDMS
regex = fetchRegexFromMdms(countryCode, tenantId, requestInfo);
rule = fetchRuleFromMdms(countryCode, tenantId, requestInfo);

// 3. Fallback to state tenant if incoming returned nothing
if (regex == null && !tenantId.equals(stateTenantId)) {
if (rule == null && !tenantId.equals(stateTenantId)) {
log.info("No MDMS config for tenantId: {}, retrying with stateTenant: {}", tenantId, stateTenantId);
regex = fetchRegexFromMdms(countryCode, stateTenantId, requestInfo);
rule = fetchRuleFromMdms(countryCode, stateTenantId, requestInfo);
}

if (regex != null) {
cacheRepository.cacheMobileRegex(stateTenantId, countryCode, regex);
if (rule != null) {
cacheRepository.cacheRule(stateTenantId, countryCode, rule);
}
}

// 4. Fallback to application.properties default
if (regex == null) {
String regex;
String resolvedCountryCode = countryCode;
if (rule == null) {
// 4. MDMS has no usable config at all (unreachable, or no matching/default entry) —
// only NOW fall back to the application.properties default.
log.warn("No MDMS validation config found for tenantId: {} countryCode: {}. Using application.properties default.",
tenantId, countryCode);
regex = defaultMobileRegex;
if (resolvedCountryCode == null) {
resolvedCountryCode = defaultCountryCode;
}
} else {
regex = rule.getRegex();
// MDMS is configured and answered: when the caller didn't pass a countryCode, prefer
// the countryCode carried by the MDMS entry we matched (or its "default" entry) over
// the application.properties fallback — MDMS wins whenever it has an answer.
if (resolvedCountryCode == null) {
resolvedCountryCode = StringUtils.hasText(rule.getCountryCode()) ? rule.getCountryCode() : defaultCountryCode;
}
}

applyRegexValidation(mobileNumber, regex);
log.info("Mobile validation successful for countryCode: {}", countryCode);
return countryCode != null ? countryCode : defaultCountryCode;
log.info("Mobile validation successful for countryCode: {}", resolvedCountryCode);
return resolvedCountryCode;
}

private void applyRegexValidation(String mobileNumber, String regex) {
Expand All @@ -139,7 +154,7 @@ private void applyRegexValidation(String mobileNumber, String regex) {
}
}

private String fetchRegexFromMdms(String countryCode, String tenantId, RequestInfo requestInfo) {
private MobileValidationRule fetchRuleFromMdms(String countryCode, String tenantId, RequestInfo requestInfo) {
try {
String url = mdmsHost + mdmsV2SearchEndpoint;
MdmsV2SearchRequest searchRequest = MdmsV2SearchRequest.builder()
Expand All @@ -159,16 +174,16 @@ private String fetchRegexFromMdms(String countryCode, String tenantId, RequestIn
return null;
}

return selectRegex(response.getMdms(), countryCode);
return selectRule(response.getMdms(), countryCode);

} catch (Exception e) {
log.error("Error fetching validation config from MDMS-v2 for tenantId: {} countryCode: {}", tenantId, countryCode, e);
return null;
}
}

private String selectRegex(List<MdmsV2Data> mdmsEntries, String countryCode) {
String defaultRegex = null;
private MobileValidationRule selectRule(List<MdmsV2Data> mdmsEntries, String countryCode) {
MobileValidationRule defaultRule = null;
for (MdmsV2Data entry : mdmsEntries) {
if (entry.getData() == null || Boolean.FALSE.equals(entry.getIsActive())) {
continue;
Expand All @@ -183,20 +198,20 @@ private String selectRegex(List<MdmsV2Data> mdmsEntries, String countryCode) {
boolean isDefault = data.has(FIELD_DEFAULT) && data.get(FIELD_DEFAULT).asBoolean(false);
String entryCountryCode = data.has(FIELD_COUNTRY_CODE) ? data.get(FIELD_COUNTRY_CODE).asText(null) : null;

if (isDefault && defaultRegex == null) {
defaultRegex = entryRegex;
if (isDefault && defaultRule == null) {
defaultRule = new MobileValidationRule(entryRegex, entryCountryCode);
}

if (StringUtils.hasText(countryCode) && countryCode.equals(entryCountryCode)) {
log.info("Found MDMS MobileNumberValidation entry for countryCode: {}", countryCode);
return entryRegex;
return new MobileValidationRule(entryRegex, entryCountryCode);
}
}

if (defaultRegex != null) {
if (defaultRule != null) {
log.info("No MDMS entry for countryCode: {}, using default entry regex.", countryCode);
}
return defaultRegex;
return defaultRule;
}

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package org.egov.user.repository;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.egov.user.domain.model.MobileValidationRule;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
Expand All @@ -10,15 +13,18 @@
import java.util.concurrent.TimeUnit;

/**
* Redis cache for MobileNumberValidation regex rules.
* Redis cache for MobileNumberValidation rules.
*
* Design: each (tenant, countryCode) pair is stored as an individual string key
* with its own TTL. This avoids the shared-hash problem where one EXPIRE call
* resets the expiry for every entry, and ensures that a pod restart does NOT
* clear the cache (since there is no @PostConstruct wipe — the TTL drives expiry).
*
* Key pattern: mobile-validation:{tenantId}:{sanitizedCountryCode}
* Value: the mobileNumberRegex string
* Key pattern: egov-user:mobile-val:{tenantId}:{sanitizedCountryCode}
* Value: {@link MobileValidationRule} serialized as JSON, carrying both the regex and the
* resolved entry's own countryCode — so a lookup keyed by a null/absent incoming
* countryCode can still recover the countryCode MDMS's own "default" entry is
* configured for, on a cache HIT and not just on a fresh MDMS fetch.
*/
@Repository
@Slf4j
Expand All @@ -30,47 +36,62 @@ public class MobileNumerValidationCacheRepository {
@Autowired
private StringRedisTemplate stringRedisTemplate;

@Autowired
private ObjectMapper objectMapper;

@Value("${egov.validation.cache.ttl.seconds:3600}")
private long cacheTtlSeconds;

/**
* Returns the cached mobileNumberRegex for the given tenant + countryCode, or null on miss.
* Returns the cached MobileValidationRule for the given tenant + countryCode, or null on miss.
*/
public String getMobileRegex(String tenantId, String countryCode) {
public MobileValidationRule getRule(String tenantId, String countryCode) {
try {
String key = buildKey(tenantId, countryCode);
String value = stringRedisTemplate.opsForValue().get(key);
if (value != null) {
log.debug("Cache HIT: key={}", key);
} else {
if (value == null) {
log.debug("Cache MISS: key={}", key);
return null;
}
return value;
MobileValidationRule rule = objectMapper.readValue(value, MobileValidationRule.class);
if (!StringUtils.hasText(rule.getRegex())) {
log.warn("Stale/incomplete cache entry at key={}, evicting.", key);
stringRedisTemplate.delete(key);
return null;
}
log.debug("Cache HIT: key={}", key);
return rule;
} catch (JsonProcessingException e) {
log.error("Cache deserialization error for tenantId={} countryCode={}", tenantId, countryCode, e);
return null;
} catch (Exception e) {
log.error("Error reading mobile regex from cache for tenantId={} countryCode={}", tenantId, countryCode, e);
log.error("Error reading mobile validation rule from cache for tenantId={} countryCode={}", tenantId, countryCode, e);
return null;
}
}

/**
* Caches the mobileNumberRegex for the given tenant + countryCode.
* Caches the MobileValidationRule for the given tenant + countryCode.
* Each key expires independently after cacheTtlSeconds.
*/
public void cacheMobileRegex(String tenantId, String countryCode, String regex) {
if (!StringUtils.hasText(regex)) {
public void cacheRule(String tenantId, String countryCode, MobileValidationRule rule) {
if (rule == null || !StringUtils.hasText(rule.getRegex())) {
return;
}
try {
String key = buildKey(tenantId, countryCode);
String value = objectMapper.writeValueAsString(rule);
if (cacheTtlSeconds > 0) {
stringRedisTemplate.opsForValue().set(key, regex, cacheTtlSeconds, TimeUnit.SECONDS);
log.debug("Cached mobile regex: key={} ttl={}s", key, cacheTtlSeconds);
stringRedisTemplate.opsForValue().set(key, value, cacheTtlSeconds, TimeUnit.SECONDS);
log.debug("Cached mobile validation rule: key={} ttl={}s", key, cacheTtlSeconds);
} else {
stringRedisTemplate.opsForValue().set(key, regex);
log.debug("Cached mobile regex (no TTL): key={}", key);
stringRedisTemplate.opsForValue().set(key, value);
log.debug("Cached mobile validation rule (no TTL): key={}", key);
}
} catch (JsonProcessingException e) {
log.error("Cache serialization error for tenantId={} countryCode={}", tenantId, countryCode, e);
} catch (Exception e) {
log.error("Error caching mobile regex for tenantId={} countryCode={}", tenantId, countryCode, e);
log.error("Error caching mobile validation rule for tenantId={} countryCode={}", tenantId, countryCode, e);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.egov.common.contract.request.RequestInfo;
import org.egov.common.utils.MultiStateInstanceUtil;
import org.egov.tracer.model.CustomException;
import org.egov.user.domain.model.MobileValidationRule;
import org.egov.user.domain.model.mdmsv2.MdmsV2Data;
import org.egov.user.domain.model.mdmsv2.MdmsV2Response;
import org.egov.user.repository.MobileNumerValidationCacheRepository;
Expand Down Expand Up @@ -54,7 +55,7 @@ public void setUp() {
String t = (String) inv.getArguments()[0];
return t.contains(".") ? t.split("\\.")[0] : t;
});
when(cacheRepository.getMobileRegex(anyString(), any())).thenReturn(null);
when(cacheRepository.getRule(anyString(), any())).thenReturn(null);
}

// -------- skip validation for blank mobile --------
Expand Down Expand Up @@ -128,7 +129,7 @@ public void test_throws_when_mdms_returns_nothing_and_number_fails_default_regex

@Test
public void test_uses_cached_regex_without_calling_mdms() {
when(cacheRepository.getMobileRegex("pb", "+91")).thenReturn("^[6-9][0-9]{9}$");
when(cacheRepository.getRule("pb", "+91")).thenReturn(new MobileValidationRule("^[6-9][0-9]{9}$", "+91"));
validator.validateMobileNumberWithCountryCode("9123456789", "+91", "pb", requestInfo);
verifyZeroInteractions(restTemplate);
}
Expand All @@ -137,7 +138,7 @@ public void test_uses_cached_regex_without_calling_mdms() {
public void test_caches_regex_after_mdms_fetch() {
stubMdmsResponse("pb", "+91", "^[6-9][0-9]{9}$", true, true);
validator.validateMobileNumberWithCountryCode("9123456789", "+91", "pb", requestInfo);
verify(cacheRepository).cacheMobileRegex(eq("pb"), eq("+91"), eq("^[6-9][0-9]{9}$"));
verify(cacheRepository).cacheRule(eq("pb"), eq("+91"), eq(new MobileValidationRule("^[6-9][0-9]{9}$", "+91")));
}

// -------- countryCode resolution --------
Expand All @@ -156,6 +157,25 @@ public void test_returns_default_countryCode_when_null_provided() {
assertEquals("+91", result);
}

// MDMS's own "default" entry must win over the application.properties default whenever MDMS
// has an answer at all — the properties value is a last resort for when MDMS has NOTHING, not
// a value MDMS is expected to agree with. Java default here ("+91") deliberately differs from
// MDMS's configured default ("+251") so the test fails if the priority order regresses.
@Test
public void test_mdms_default_countryCode_wins_over_application_properties_default() {
stubMdmsResponse("et", "+251", "^[79][0-9]{8}$", true, true);
String result = validator.validateMobileNumberWithCountryCode("712345678", null, "et", requestInfo);
assertEquals("+251", result);
}

@Test
public void test_mdms_default_countryCode_wins_on_cache_hit_too() {
when(cacheRepository.getRule("et", null)).thenReturn(new MobileValidationRule("^[79][0-9]{8}$", "+251"));
String result = validator.validateMobileNumberWithCountryCode("712345678", null, "et", requestInfo);
assertEquals("+251", result);
verifyZeroInteractions(restTemplate);
}

// -------- inactive entry skipped --------

@Test
Expand Down
Loading
Loading