ChatRealtimeProperties.java
package com.ecommerce.chat.infrastructure.realtime;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.net.URI;
import java.time.Duration;
import java.util.List;
@Validated
@ConfigurationProperties("ecommerce.chat.realtime")
public record ChatRealtimeProperties(
boolean enabled,
List<String> allowedOrigins,
@NotBlank
@Pattern(regexp = "[A-Za-z0-9_-]+")
String namespace,
@NotBlank
@Pattern(regexp = "[A-Za-z0-9_-]+")
String nodeId,
@NotBlank String endpoints,
@NotBlank String sourceTopic,
@NotBlank String deliveryTopic,
@NotBlank String dispatcherConsumerGroup,
@NotBlank String deliveryConsumerGroupPrefix,
@Min(0) long initialDelay,
@Min(100) long fixedDelay,
@NotNull Duration awaitDuration,
@NotNull Duration invisibleDuration,
@Min(1) @Max(100) int batchSize,
@NotNull Duration presenceTtl,
@NotNull Duration refreshInterval,
@Min(1) @Max(500) int offlineReplayBatchSize
) {
private static final List<String> DEFAULT_ALLOWED_ORIGINS = List.of(
"http://127.0.0.1:18200",
"http://127.0.0.1:18201",
"http://localhost:18200",
"http://localhost:18201");
public ChatRealtimeProperties {
allowedOrigins = normalizeAllowedOrigins(allowedOrigins);
namespace = namespace == null || namespace.isBlank() ? "local" : namespace;
nodeId = nodeId == null || nodeId.isBlank() ? "chat-local" : nodeId;
endpoints = endpoints == null || endpoints.isBlank()
? "127.0.0.1:18082"
: endpoints;
sourceTopic = sourceTopic == null || sourceTopic.isBlank()
? "ecommerce-chat-events"
: sourceTopic;
deliveryTopic = deliveryTopic == null || deliveryTopic.isBlank()
? "ecommerce-chat-delivery-events"
: deliveryTopic;
dispatcherConsumerGroup = dispatcherConsumerGroup == null
|| dispatcherConsumerGroup.isBlank()
? "ecommerce-chat-dispatcher"
: dispatcherConsumerGroup;
deliveryConsumerGroupPrefix = deliveryConsumerGroupPrefix == null
|| deliveryConsumerGroupPrefix.isBlank()
? "ecommerce-chat-delivery"
: deliveryConsumerGroupPrefix;
fixedDelay = fixedDelay <= 0 ? 500 : fixedDelay;
awaitDuration = awaitDuration == null ? Duration.ofSeconds(5) : awaitDuration;
invisibleDuration = invisibleDuration == null ? Duration.ofSeconds(15) : invisibleDuration;
batchSize = batchSize <= 0 ? 20 : batchSize;
presenceTtl = presenceTtl == null ? Duration.ofSeconds(12) : presenceTtl;
refreshInterval = refreshInterval == null ? Duration.ofSeconds(4) : refreshInterval;
offlineReplayBatchSize = offlineReplayBatchSize <= 0 ? 100 : offlineReplayBatchSize;
requirePositive(awaitDuration, "awaitDuration");
requirePositive(invisibleDuration, "invisibleDuration");
requirePositive(presenceTtl, "presenceTtl");
requirePositive(refreshInterval, "refreshInterval");
if (presenceTtl != null && refreshInterval != null
&& presenceTtl.compareTo(refreshInterval.multipliedBy(2)) < 0) {
throw new IllegalArgumentException("presenceTtl must be at least twice refreshInterval");
}
}
private static List<String> normalizeAllowedOrigins(List<String> configured) {
List<String> origins = configured == null || configured.isEmpty()
? DEFAULT_ALLOWED_ORIGINS
: configured.stream()
.map(String::trim)
.filter(value -> !value.isEmpty())
.distinct()
.toList();
if (origins.isEmpty()) {
throw new IllegalArgumentException("allowedOrigins must not be empty");
}
for (String origin : origins) {
URI uri;
try {
uri = URI.create(origin);
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException(
"allowedOrigins contains an invalid origin", exception);
}
boolean supportedScheme = "http".equalsIgnoreCase(uri.getScheme())
|| "https".equalsIgnoreCase(uri.getScheme());
if (!supportedScheme
|| uri.getHost() == null
|| uri.getUserInfo() != null
|| (uri.getPath() != null && !uri.getPath().isEmpty())
|| uri.getQuery() != null
|| uri.getFragment() != null
|| origin.contains("*")) {
throw new IllegalArgumentException(
"allowedOrigins must contain exact HTTP(S) origins");
}
}
return List.copyOf(origins);
}
public String deliveryConsumerGroup() {
return deliveryConsumerGroupPrefix + "-" + nodeId;
}
public String redisKeyPrefix() {
return "ecommerce:" + namespace + ":chat:";
}
public String nodeTag() {
return nodeTag(nodeId);
}
public String nodeTag(String targetNodeId) {
if (targetNodeId == null || !targetNodeId.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException("targetNodeId contains unsupported characters");
}
return "NODE_" + targetNodeId;
}
private static void requirePositive(Duration value, String name) {
if (value != null && (value.isNegative() || value.isZero())) {
throw new IllegalArgumentException(name + " must be greater than zero");
}
}
}