diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureCatalog.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureCatalog.java index 4046417..3c8dbfe 100644 --- a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureCatalog.java +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureCatalog.java @@ -6,6 +6,12 @@ /** Read-only catalog of all features managed by one host plugin. */ public interface FeatureCatalog { Optional find(FeatureId id); + + /** Finds a feature from external text, returning empty for null or malformed identifiers. */ + default Optional findByName(String id) { + return FeatureId.tryParse(id).flatMap(this::find); + } + List snapshot(); AutoCloseable subscribe(FeatureCatalogListener listener); } diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureId.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureId.java index 366e44f..455a01f 100644 --- a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureId.java +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureId.java @@ -2,6 +2,7 @@ import java.util.Locale; import java.util.Objects; +import java.util.Optional; /** Stable, normalized identity of a managed feature. */ public record FeatureId(String value) implements Comparable { @@ -9,11 +10,11 @@ public record FeatureId(String value) implements Comparable { public FeatureId { Objects.requireNonNull(value, "value"); - value = value.trim().toLowerCase(Locale.ROOT); + value = normalize(value); if (value.length() > MAX_LENGTH) { throw new IllegalArgumentException("Feature id exceeds " + MAX_LENGTH + " characters"); } - if (!isValid(value)) { + if (!isNormalizedValueValid(value)) { throw new IllegalArgumentException("Invalid feature id: " + value); } } @@ -22,6 +23,23 @@ public static FeatureId of(String value) { return new FeatureId(value); } + /** + * Returns whether a raw value can be normalized into a valid feature id. + * + *

This applies the same trimming and case normalization as {@link #of(String)} without + * throwing for null or malformed input.

+ */ + public static boolean isValid(String value) { + if (value == null) return false; + String normalized = normalize(value); + return normalized.length() <= MAX_LENGTH && isNormalizedValueValid(normalized); + } + + /** Parses a feature id without throwing for null or malformed external input. */ + public static Optional tryParse(String value) { + return isValid(value) ? Optional.of(new FeatureId(value)) : Optional.empty(); + } + @Override public int compareTo(FeatureId other) { return value.compareTo(Objects.requireNonNull(other, "other").value); @@ -32,7 +50,11 @@ public String toString() { return value; } - private static boolean isValid(String value) { + private static String normalize(String value) { + return value.trim().toLowerCase(Locale.ROOT); + } + + private static boolean isNormalizedValueValid(String value) { if (value.isEmpty() || !Character.isLetterOrDigit(value.charAt(0))) { return false; } diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureSnapshot.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureSnapshot.java index 097dee7..45bee65 100644 --- a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureSnapshot.java +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureSnapshot.java @@ -56,4 +56,11 @@ public FeatureSnapshot( ); } + public boolean active() { + return state == FeatureState.ACTIVE; + } + + public boolean failed() { + return state == FeatureState.FAILED; + } } diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/network/ServerId.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/network/ServerId.java index 29588dc..1280ade 100644 --- a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/network/ServerId.java +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/network/ServerId.java @@ -10,21 +10,15 @@ public record ServerId(String value) implements Comparable { public ServerId { Objects.requireNonNull(value, "value"); - value = value.trim().toLowerCase(Locale.ROOT); + value = normalize(value); if (value.length() > MAX_LENGTH) { throw new IllegalArgumentException("Server id exceeds " + MAX_LENGTH + " characters"); } if (value.isEmpty()) { throw new IllegalArgumentException("server id must not be blank"); } - for (int index = 0; index < value.length(); index++) { - char character = value.charAt(index); - if (!Character.isLetterOrDigit(character) - && character != '-' - && character != '_' - && character != '.') { - throw new IllegalArgumentException("Invalid server id: " + value); - } + if (!hasValidCharacters(value)) { + throw new IllegalArgumentException("Invalid server id: " + value); } } @@ -32,6 +26,20 @@ public static ServerId of(String value) { return new ServerId(value); } + /** Returns whether a raw value can be normalized into a valid server id. */ + public static boolean isValid(String value) { + if (value == null) return false; + String normalized = normalize(value); + return !normalized.isEmpty() + && normalized.length() <= MAX_LENGTH + && hasValidCharacters(normalized); + } + + /** Parses a server id without throwing for null or malformed external input. */ + public static Optional tryParse(String value) { + return isValid(value) ? Optional.of(new ServerId(value)) : Optional.empty(); + } + public static Optional optional(String value) { return value == null || value.isBlank() ? Optional.empty() : Optional.of(new ServerId(value)); } @@ -45,4 +53,21 @@ public int compareTo(ServerId other) { public String toString() { return value; } + + private static String normalize(String value) { + return value.trim().toLowerCase(Locale.ROOT); + } + + private static boolean hasValidCharacters(String value) { + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (!Character.isLetterOrDigit(character) + && character != '-' + && character != '_' + && character != '.') { + return false; + } + } + return true; + } } diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/service/CapabilityRegistry.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/service/CapabilityRegistry.java index 91ca7cf..1de4668 100644 --- a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/service/CapabilityRegistry.java +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/service/CapabilityRegistry.java @@ -1,5 +1,6 @@ package nl.hauntedmc.featureframework.api.service; +import java.util.Optional; import java.util.Set; /** Read-only catalog of feature capabilities provided by the current runtime. */ @@ -7,6 +8,21 @@ public interface CapabilityRegistry { /** Returns a stable reference for the requested public contract. */ CapabilityRef reference(Class type); + /** Resolves the currently active implementation, if available. */ + default Optional findCapability(Class type) { + return reference(type).get(); + } + + /** Resolves the currently active implementation or fails with a descriptive exception. */ + default T requireCapability(Class type) { + return reference(type).require(); + } + + /** Returns whether the requested contract currently has an active provider. */ + default boolean hasCapability(Class type) { + return reference(type).isAvailable(); + } + /** Returns the contracts that currently have an active provider. */ Set> availableTypes(); diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/command/FeatureCommandModel.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/command/FeatureCommandModel.java index a6e8ebe..4ddde27 100644 --- a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/command/FeatureCommandModel.java +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/command/FeatureCommandModel.java @@ -3,7 +3,6 @@ import nl.hauntedmc.featureframework.api.feature.FeatureCatalog; import nl.hauntedmc.featureframework.api.feature.FeatureId; import nl.hauntedmc.featureframework.api.feature.FeatureSnapshot; -import nl.hauntedmc.featureframework.api.feature.FeatureState; import java.util.Comparator; import java.util.List; @@ -73,14 +72,15 @@ private List suggestions(String prefix, boolean loadedOnly) { private Optional find(String requestedName) { if (requestedName == null || requestedName.isBlank()) return Optional.empty(); + String normalizedName = requestedName.trim(); return catalog.snapshot().stream().filter(snapshot -> - snapshot.metadata().id().value().equalsIgnoreCase(requestedName) - || snapshot.metadata().displayName().equalsIgnoreCase(requestedName)) + snapshot.metadata().id().value().equalsIgnoreCase(normalizedName) + || snapshot.metadata().displayName().equalsIgnoreCase(normalizedName)) .findFirst(); } private static boolean loaded(FeatureSnapshot snapshot) { - return snapshot.state() == FeatureState.ACTIVE; + return snapshot.active(); } private static Comparator byId() { diff --git a/featureframework-api/src/test/java/nl/hauntedmc/featureframework/api/FrameworkApiContractsTest.java b/featureframework-api/src/test/java/nl/hauntedmc/featureframework/api/FrameworkApiContractsTest.java index 113ed39..349d1b2 100644 --- a/featureframework-api/src/test/java/nl/hauntedmc/featureframework/api/FrameworkApiContractsTest.java +++ b/featureframework-api/src/test/java/nl/hauntedmc/featureframework/api/FrameworkApiContractsTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test; import java.time.Instant; +import java.util.List; import java.util.Optional; import java.util.Set; @@ -27,12 +28,58 @@ void validatesIdentifiersAndMetadata() { assertEquals("queue", id.value()); assertEquals("Queue", metadata.displayName()); + assertTrue(FeatureId.isValid(" Queue ")); + assertFalse(FeatureId.isValid("bad id")); + assertFalse(FeatureId.isValid(null)); + assertEquals(Optional.of(id), FeatureId.tryParse(" Queue ")); + assertEquals(Optional.empty(), FeatureId.tryParse("bad id")); assertThrows(IllegalArgumentException.class, () -> FeatureId.of("bad id")); assertThrows(IllegalArgumentException.class, () -> new FeatureMetadata( id, " ", "1", Set.of(), Set.of(), Set.of() )); } + @Test + void featureCatalogAcceptsExternalTextIds() { + FeatureMetadata metadata = new FeatureMetadata( + FeatureId.of("demo"), "Demo", "1", Set.of(), Set.of(), Set.of() + ); + FeatureSnapshot snapshot = new FeatureSnapshot( + metadata, + true, + FeatureState.ACTIVE, + Optional.empty(), + Set.of(), + Instant.EPOCH, + Optional.empty(), + 1, + Instant.EPOCH + ); + FeatureCatalog catalog = new FeatureCatalog() { + @Override + public Optional find(FeatureId id) { + return id.equals(metadata.id()) ? Optional.of(snapshot) : Optional.empty(); + } + + @Override + public List snapshot() { + return List.of(snapshot); + } + + @Override + public AutoCloseable subscribe(FeatureCatalogListener listener) { + return () -> { }; + } + }; + + assertEquals(Optional.of(snapshot), catalog.findByName(" DEMO ")); + assertEquals(Optional.empty(), catalog.findByName("missing")); + assertEquals(Optional.empty(), catalog.findByName("bad id")); + assertEquals(Optional.empty(), catalog.findByName(null)); + assertTrue(snapshot.active()); + assertFalse(snapshot.failed()); + } + @Test void providesBothHumanAndTypedFailureProjections() { FeatureMetadata metadata = new FeatureMetadata( @@ -53,6 +100,8 @@ void providesBothHumanAndTypedFailureProjections() { assertEquals(Optional.of("boom"), snapshot.failure()); assertEquals(Optional.of(failure), snapshot.failureDetail()); + assertTrue(snapshot.failed()); + assertFalse(snapshot.active()); assertThrows(IllegalArgumentException.class, () -> new FeatureSnapshot( metadata, true, FeatureState.ACTIVE, Optional.empty(), Set.of(), Instant.EPOCH, Optional.empty(), -1, Instant.EPOCH @@ -61,7 +110,14 @@ void providesBothHumanAndTypedFailureProjections() { @Test void validatesServerIdsAndCapabilityFailures() { - assertEquals("hub", ServerId.of(" HUB ").value()); + ServerId hub = ServerId.of(" HUB "); + assertEquals("hub", hub.value()); + assertTrue(ServerId.isValid(" HUB ")); + assertFalse(ServerId.isValid("bad id")); + assertFalse(ServerId.isValid(null)); + assertEquals(Optional.of(hub), ServerId.tryParse(" HUB ")); + assertTrue(ServerId.tryParse("bad id").isEmpty()); + CapabilityUnavailableException failure = new CapabilityUnavailableException(Runnable.class); assertEquals(Runnable.class, failure.capabilityType()); assertTrue(failure.getMessage().contains(Runnable.class.getName())); diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/config/FeatureConfigHandler.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/config/FeatureConfigHandler.java index 5d41172..f2dee37 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/config/FeatureConfigHandler.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/config/FeatureConfigHandler.java @@ -48,6 +48,13 @@ public void registerReloadListener(Runnable listener) { reloadListeners.add(Objects.requireNonNull(listener, "listener")); } + /** Registers a reload listener that can be removed independently by closing the returned handle. */ + public AutoCloseable subscribeReload(Runnable listener) { + Runnable required = Objects.requireNonNull(listener, "listener"); + reloadListeners.add(required); + return () -> reloadListeners.remove(required); + } + public void clearReloadListeners() { reloadListeners.clear(); } diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/config/FeatureStoragePaths.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/config/FeatureStoragePaths.java index 908d7df..0048747 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/config/FeatureStoragePaths.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/config/FeatureStoragePaths.java @@ -33,12 +33,24 @@ public static String messagesPath(String featureName, Language language) { public static String localDataPath(String fileName) { String normalized = Objects.requireNonNull(fileName, "fileName").trim(); - if (!VALID_LOCAL_DATA_FILE.matcher(normalized).matches()) { + if (!isValidLocalDataFileName(normalized)) { throw new IllegalArgumentException("Invalid local YAML file name: " + fileName); } return "local/" + normalized; } + /** Returns whether a value can be used as one feature storage directory name. */ + public static boolean isValidFeatureName(String featureName) { + if (featureName == null) return false; + String normalized = featureName.trim(); + return !normalized.isEmpty() && VALID_FEATURE_NAME.matcher(normalized).matches(); + } + + /** Returns whether a value is a safe local YAML file name without directory segments. */ + public static boolean isValidLocalDataFileName(String fileName) { + return fileName != null && VALID_LOCAL_DATA_FILE.matcher(fileName.trim()).matches(); + } + public static String normalizeFeatureName(String featureName) { String normalized = Objects.requireNonNull(featureName, "featureName").trim(); if (normalized.isEmpty()) { diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/dependency/DependencyCheckResult.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/dependency/DependencyCheckResult.java index 0451c08..b35f1fa 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/dependency/DependencyCheckResult.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/dependency/DependencyCheckResult.java @@ -13,4 +13,12 @@ public DependencyCheckResult(Set missingPluginDependencies, Set public boolean ok() { return missingPluginDependencies.isEmpty() && missingFeatureDependencies.isEmpty(); } + + public boolean hasMissingDependencies() { + return !ok(); + } + + public int missingDependencyCount() { + return missingPluginDependencies.size() + missingFeatureDependencies.size(); + } } diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureCollection.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureCollection.java index 7c8c7c6..1e8644d 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureCollection.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureCollection.java @@ -51,6 +51,10 @@ public List> definitions() { return definitions; } + public int size() { + return definitions.size(); + } + /** Builder useful when a product composes definitions from several feature packs. */ public static final class Builder { private final List> definitions = new ArrayList<>(); @@ -63,6 +67,12 @@ public Builder feature(FeatureDefinition definition) { return this; } + /** Adds an ordered batch of definitions without forcing callers to loop manually. */ + public Builder features(Iterable> values) { + Objects.requireNonNull(values, "definitions").forEach(this::feature); + return this; + } + public Builder include(FeatureCollection collection) { Objects.requireNonNull(collection, "collection"); collection.definitions().forEach(this::feature); diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureDefinition.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureDefinition.java index 70f2d9b..392174c 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureDefinition.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureDefinition.java @@ -49,7 +49,6 @@ private FeatureDefinition(Builder builder) { constructor = Objects.requireNonNull(builder.constructor, "constructor"); startupPhase = builder.startupPhase; enabledByDefault = builder.enabledByDefault; - roles = immutableRoles(builder.roles); requiredFeatures = immutableText(builder.requiredFeatures, "requiredFeatures"); optionalFeatures = withoutRequired( immutableText(builder.optionalFeatures, "optionalFeatures"), requiredFeatures); @@ -71,6 +70,7 @@ private FeatureDefinition(Builder builder) { optionalResourceExtensions = withoutRequiredTypes( immutableTypes(builder.optionalResourceExtensions, "optionalResourceExtensions"), requiredResourceExtensions); + roles = effectiveRoles(builder.roles, requiredCapabilities, optionalCapabilities, providedCapabilities); ensureDisjoint(requiredCapabilities, providedCapabilities, "required", "provided capability"); ensureDisjoint(optionalCapabilities, providedCapabilities, "optional", "provided capability"); ensureDisjoint(requiredInternalServices, providedInternalServices, "required", "provided internal service"); @@ -101,10 +101,7 @@ public static Builder builder( @Override public Set roles() { - EnumSet result = roles.isEmpty() - ? EnumSet.noneOf(FeatureRole.class) : EnumSet.copyOf(roles); - result.addAll(FeatureManifestDefinition.super.roles()); - return Set.copyOf(result); + return roles; } @Override public Set> requiredCapabilities() { return requiredCapabilities; } @@ -245,9 +242,19 @@ public FeatureDefinition build() { } } - private static Set immutableRoles(Set values) { - if (values.isEmpty()) return Set.of(); - return Collections.unmodifiableSet(EnumSet.copyOf(values)); + private static Set effectiveRoles( + Set declared, + Set> requiredCapabilities, + Set> optionalCapabilities, + Set> providedCapabilities + ) { + EnumSet result = declared.isEmpty() + ? EnumSet.noneOf(FeatureRole.class) : EnumSet.copyOf(declared); + if (!providedCapabilities.isEmpty()) result.add(FeatureRole.CAPABILITY_PROVIDER); + if (!requiredCapabilities.isEmpty() || !optionalCapabilities.isEmpty()) { + result.add(FeatureRole.CAPABILITY_CONSUMER); + } + return result.isEmpty() ? Set.of() : Collections.unmodifiableSet(result); } private static Set immutableText(Set values, String field) { diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/resource/FeatureResourceExtensions.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/resource/FeatureResourceExtensions.java index 3db794f..ce9c5d5 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/resource/FeatureResourceExtensions.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/resource/FeatureResourceExtensions.java @@ -1,9 +1,11 @@ package nl.hauntedmc.featureframework.resource; +import java.util.LinkedHashSet; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; /** Typed extensions attached to one feature generation by host contributors. */ public final class FeatureResourceExtensions { @@ -30,4 +32,22 @@ public T require(ResourceKey key) { public synchronized boolean contains(Class type) { return values.keySet().stream().anyMatch(key -> key.type().equals(type)); } + + /** Returns whether the exact typed resource key is registered. */ + public synchronized boolean containsKey(ResourceKey key) { + return values.containsKey(Objects.requireNonNull(key, "key")); + } + + /** Returns an immutable snapshot of the currently registered resource keys. */ + public synchronized Set> keys() { + return java.util.Collections.unmodifiableSet(new LinkedHashSet<>(values.keySet())); + } + + public synchronized int size() { + return values.size(); + } + + public synchronized boolean isEmpty() { + return values.isEmpty(); + } } diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/service/InternalServiceRegistry.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/service/InternalServiceRegistry.java index 7ca147e..b6a7bb5 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/service/InternalServiceRegistry.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/service/InternalServiceRegistry.java @@ -2,6 +2,7 @@ import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; @@ -54,6 +55,23 @@ public T require(Class type) { )); } + public boolean isAvailable(Class type) { + return providers.containsKey(Objects.requireNonNull(type, "type")); + } + + /** Returns an immutable snapshot of currently registered service contracts. */ + public Set> availableTypes() { + return Set.copyOf(providers.keySet()); + } + + public int size() { + return providers.size(); + } + + public boolean isEmpty() { + return providers.isEmpty(); + } + public Optional owner(Class type) { Provider provider = providers.get(Objects.requireNonNull(type, "type")); return provider == null ? Optional.empty() : Optional.of(provider.owner()); diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/command/FeatureCommandModelTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/command/FeatureCommandModelTest.java index 0583786..04b8b74 100644 --- a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/command/FeatureCommandModelTest.java +++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/command/FeatureCommandModelTest.java @@ -25,8 +25,8 @@ void buildsInfoListsAndCandidatesFromPublicCatalogSnapshots() { catalog.transition(alpha, FeatureState.ACTIVE); FeatureCommandModel model = new FeatureCommandModel(catalog); - FeatureCommandModel.FeatureInfo loaded = model.info("ALPHA").orElseThrow(); - FeatureCommandModel.FeatureInfo available = model.info("BETA").orElseThrow(); + FeatureCommandModel.FeatureInfo loaded = model.info(" ALPHA ").orElseThrow(); + FeatureCommandModel.FeatureInfo available = model.info(" Beta ").orElseThrow(); assertTrue(loaded.enabled()); assertEquals(List.of("core"), loaded.featureDependencies()); diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/config/FeatureStoragePathsTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/config/FeatureStoragePathsTest.java index f5936d1..8b410f4 100644 --- a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/config/FeatureStoragePathsTest.java +++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/config/FeatureStoragePathsTest.java @@ -3,8 +3,7 @@ import nl.hauntedmc.featureframework.toolkit.io.localization.Language; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; class FeatureStoragePathsTest { @@ -25,10 +24,15 @@ void buildsSafeLocalDataPaths() { FeatureStoragePaths.localDataPath(" commandscheduler.yml ") ); assertEquals("local/recipes.yaml", FeatureStoragePaths.localDataPath("recipes.yaml")); + assertTrue(FeatureStoragePaths.isValidLocalDataFileName(" recipes.yaml ")); + assertFalse(FeatureStoragePaths.isValidLocalDataFileName("nested/recipes.yml")); } @Test void rejectsUnsafeFeatureNames() { + assertTrue(FeatureStoragePaths.isValidFeatureName(" Demo-Feature_2 ")); + assertFalse(FeatureStoragePaths.isValidFeatureName("../Demo")); + assertFalse(FeatureStoragePaths.isValidFeatureName(null)); assertThrows(IllegalArgumentException.class, () -> FeatureStoragePaths.configPath("../Demo")); assertThrows(IllegalArgumentException.class, () -> FeatureStoragePaths.configPath("Demo/Child")); assertThrows(IllegalArgumentException.class, () -> FeatureStoragePaths.configPath(" ")); diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/dependency/DependencyCheckResultTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/dependency/DependencyCheckResultTest.java index 192c893..d73ec1d 100644 --- a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/dependency/DependencyCheckResultTest.java +++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/dependency/DependencyCheckResultTest.java @@ -13,12 +13,16 @@ class DependencyCheckResultTest { void okIsTrueWhenNothingMissing() { DependencyCheckResult result = new DependencyCheckResult(Set.of(), Set.of()); assertTrue(result.ok()); + assertFalse(result.hasMissingDependencies()); + assertEquals(0, result.missingDependencyCount()); } @Test void okIsFalseWhenAnyDependencyMissing() { - assertFalse(new DependencyCheckResult(Set.of("Vault"), Set.of()).ok()); - assertFalse(new DependencyCheckResult(Set.of(), Set.of("OtherFeature")).ok()); + DependencyCheckResult result = new DependencyCheckResult(Set.of("Vault"), Set.of("OtherFeature")); + assertFalse(result.ok()); + assertTrue(result.hasMissingDependencies()); + assertEquals(2, result.missingDependencyCount()); } @Test diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureCollectionTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureCollectionTest.java new file mode 100644 index 0000000..36959b9 --- /dev/null +++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureCollectionTest.java @@ -0,0 +1,61 @@ +package nl.hauntedmc.featureframework.host; + +import nl.hauntedmc.featureframework.api.feature.FeatureRole; +import nl.hauntedmc.featureframework.feature.Feature; +import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMap; +import nl.hauntedmc.featureframework.toolkit.io.localization.MessageMap; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FeatureCollectionTest { + @Test + void builderAcceptsOrderedBatchesAndReportsSize() { + FeatureDefinition first = definition("First"); + FeatureDefinition second = definition("Second"); + + FeatureCollection collection = FeatureCollection.builder() + .features(List.of(first, second)) + .build(); + + assertEquals(2, collection.size()); + assertEquals(List.of(first, second), collection.definitions()); + } + + @Test + void effectiveRolesAreDerivedOnceForImmutableDefinitions() { + FeatureDefinition definition = FeatureDefinition.builder( + "Capability", "1", DummyFeature.class, ignored -> new DummyFeature("Capability")) + .providesCapabilities(Runnable.class) + .build(); + + assertTrue(definition.roles().contains(FeatureRole.CAPABILITY_PROVIDER)); + assertSame(definition.roles(), definition.roles()); + } + + private static FeatureDefinition definition(String name) { + return FeatureDefinition.builder( + name, "1", DummyFeature.class, ignored -> new DummyFeature(name)).build(); + } + + private static final class DummyFeature implements Feature { + private final String name; + + private DummyFeature(String name) { + this.name = name; + } + + @Override public String name() { return name; } + @Override public String version() { return "1"; } + @Override public List dependencies() { return List.of(); } + @Override public List pluginDependencies() { return List.of(); } + @Override public ConfigMap defaultConfig() { return new ConfigMap(); } + @Override public MessageMap defaultMessages() { return new MessageMap(); } + @Override public void initialize() { } + @Override public void disable() { } + } +} diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/resource/FeatureResourceInfrastructureTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/resource/FeatureResourceInfrastructureTest.java index 03dccbb..22a5b09 100644 --- a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/resource/FeatureResourceInfrastructureTest.java +++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/resource/FeatureResourceInfrastructureTest.java @@ -5,20 +5,26 @@ import java.util.ArrayList; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; class FeatureResourceInfrastructureTest { @Test - void extensionsAreTypedUniqueAndRequiredExplicitly() { + void extensionsAreTypedUniqueAndInspectable() { FeatureResourceExtensions extensions = new FeatureResourceExtensions(); ResourceKey key = ResourceKey.of(String.class); + assertTrue(extensions.isEmpty()); extensions.register(key, "value"); assertEquals("value", extensions.require(key)); assertTrue(extensions.contains(String.class)); + assertTrue(extensions.containsKey(key)); + assertEquals(Set.of(key), extensions.keys()); + assertEquals(1, extensions.size()); + assertFalse(extensions.isEmpty()); assertThrows(IllegalStateException.class, () -> extensions.register(key, "replacement")); assertThrows(IllegalStateException.class, () -> extensions.require(ResourceKey.of(Integer.class))); diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/service/DefaultCapabilityRegistryTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/service/DefaultCapabilityRegistryTest.java index c567619..f7a3622 100644 --- a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/service/DefaultCapabilityRegistryTest.java +++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/service/DefaultCapabilityRegistryTest.java @@ -16,16 +16,22 @@ void publishesReplacesAndWithdrawsGenerationSafeCapabilities() { Registration stale = registry.register(owner, TestCapability.class, first); var reference = registry.reference(TestCapability.class); + assertTrue(registry.hasCapability(TestCapability.class)); + assertEquals("first", registry.findCapability(TestCapability.class).orElseThrow().value()); + assertEquals("first", registry.requireCapability(TestCapability.class).value()); assertEquals("first", reference.require().value()); long firstGeneration = reference.generation().orElseThrow(); Registration active = registry.replace(owner, TestCapability.class, replacement); stale.close(); assertEquals("replacement", reference.require().value()); + assertEquals("replacement", registry.requireCapability(TestCapability.class).value()); assertEquals(firstGeneration + 1, reference.generation().orElseThrow()); active.close(); assertFalse(reference.isAvailable()); + assertFalse(registry.hasCapability(TestCapability.class)); + assertTrue(registry.findCapability(TestCapability.class).isEmpty()); } @Test diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/service/InternalServiceRegistryTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/service/InternalServiceRegistryTest.java index 6432539..f1917ab 100644 --- a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/service/InternalServiceRegistryTest.java +++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/service/InternalServiceRegistryTest.java @@ -2,18 +2,26 @@ import org.junit.jupiter.api.Test; +import java.util.Set; + import static org.junit.jupiter.api.Assertions.*; class InternalServiceRegistryTest { private interface Greeting { String text(); } @Test - void registrationsAreOwnedReplaceableAndIdempotentlyClosed() { + void registrationsAreOwnedReplaceableAndInspectable() { InternalServiceRegistry registry = new InternalServiceRegistry<>(); + assertTrue(registry.isEmpty()); + Registration first = registry.register("alpha", Greeting.class, () -> "one"); assertEquals("one", registry.require(Greeting.class).text()); assertEquals("alpha", registry.owner(Greeting.class).orElseThrow()); + assertTrue(registry.isAvailable(Greeting.class)); + assertEquals(Set.of(Greeting.class), registry.availableTypes()); + assertEquals(1, registry.size()); + assertFalse(registry.isEmpty()); assertThrows(IllegalStateException.class, () -> registry.replace("beta", Greeting.class, () -> "other")); @@ -23,6 +31,10 @@ void registrationsAreOwnedReplaceableAndIdempotentlyClosed() { second.close(); second.close(); assertTrue(registry.find(Greeting.class).isEmpty()); + assertFalse(registry.isAvailable(Greeting.class)); + assertTrue(registry.availableTypes().isEmpty()); + assertEquals(0, registry.size()); + assertTrue(registry.isEmpty()); } @Test diff --git a/featureframework-dataprovider/src/main/java/nl/hauntedmc/featureframework/integration/dataprovider/DataProviderResources.java b/featureframework-dataprovider/src/main/java/nl/hauntedmc/featureframework/integration/dataprovider/DataProviderResources.java index 70496e6..c7e33e7 100644 --- a/featureframework-dataprovider/src/main/java/nl/hauntedmc/featureframework/integration/dataprovider/DataProviderResources.java +++ b/featureframework-dataprovider/src/main/java/nl/hauntedmc/featureframework/integration/dataprovider/DataProviderResources.java @@ -19,6 +19,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; @@ -26,7 +27,7 @@ * Feature-scoped DataProvider resource owner shared by every platform. * Database scopes, ORM contexts, messaging handles, and cleanup are isolated per feature. */ -public class DataProviderResources { +public class DataProviderResources implements AutoCloseable { public static final ResourceKey KEY = ResourceKey.of(DataProviderResources.class); private static final String DEFAULT_PLAYER_ORM_IDENTIFIER = "playerOrmContext"; private static final String DEFAULT_SYSTEM_ORM_IDENTIFIER = "systemOrmContext"; @@ -114,6 +115,14 @@ public void quiesce() { public FeatureResourceState state() { return state; } public boolean isInitialized() { return initialized; } + public boolean isBound() { return featureName != null; } + public Optional boundFeatureName() { return Optional.ofNullable(featureName); } + public int activeConnectionCount() { return connections.size(); } + public int activeOrmContextCount() { return ormContexts.size(); } + public boolean hasConnection(String identifier) { return hasText(identifier) && connections.containsKey(identifier); } + public boolean hasOrmContext(String identifier) { return hasText(identifier) && ormContexts.containsKey(identifier); } + public Set connectionIdentifiers() { return Set.copyOf(connections.keySet()); } + public Set ormContextIdentifiers() { return Set.copyOf(ormContexts.keySet()); } private boolean initializeBoundFeature() { requireOpen(); @@ -291,8 +300,11 @@ public synchronized void closeAllDataResources() { if (failure != null) throwUnchecked(failure); } + @Override + public void close() { closeAllDataResources(); } + public void closeAllConnections() { closeAllDataResources(); } - public int getActiveConnectionCount() { return connections.size(); } + public int getActiveConnectionCount() { return activeConnectionCount(); } public int getActiveConnCount() { return getActiveConnectionCount(); } private Optional api() { diff --git a/featureframework-dataprovider/src/test/java/nl/hauntedmc/featureframework/integration/dataprovider/DataProviderResourcesTest.java b/featureframework-dataprovider/src/test/java/nl/hauntedmc/featureframework/integration/dataprovider/DataProviderResourcesTest.java index 5485cc3..af19e06 100644 --- a/featureframework-dataprovider/src/test/java/nl/hauntedmc/featureframework/integration/dataprovider/DataProviderResourcesTest.java +++ b/featureframework-dataprovider/src/test/java/nl/hauntedmc/featureframework/integration/dataprovider/DataProviderResourcesTest.java @@ -8,11 +8,13 @@ import nl.hauntedmc.dataprovider.database.messaging.MessagingDataAccess; import nl.hauntedmc.dataprovider.database.messaging.MessagingDatabaseProvider; import nl.hauntedmc.dataprovider.database.relational.RelationalDatabaseProvider; +import nl.hauntedmc.featureframework.lifecycle.FeatureResourceState; import nl.hauntedmc.featureframework.toolkit.log.FrameworkLogger; import org.junit.jupiter.api.Test; import javax.sql.DataSource; import java.util.Optional; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -30,15 +32,24 @@ void strictRegistrationTracksConnectedProviderAndCleansItUp() { when(scope.registerDatabaseOrThrow(DatabaseType.MYSQL, "default")).thenReturn(provider); DataProviderResources manager = manager(api); + assertFalse(manager.isBound()); manager.initializeForFeature("Queue"); + assertTrue(manager.isBound()); + assertEquals(Optional.of("Queue"), manager.boundFeatureName()); assertSame(provider, manager.registerConnection("main", DatabaseType.MYSQL, "default").orElseThrow()); + assertTrue(manager.hasConnection("main")); + assertEquals(Set.of("main"), manager.connectionIdentifiers()); + assertEquals(1, manager.activeConnectionCount()); + assertEquals(0, manager.activeOrmContextCount()); assertEquals(1, manager.getActiveConnCount()); - manager.closeAllConnections(); + manager.close(); verify(scope).registerDatabaseOrThrow(DatabaseType.MYSQL, "default"); verify(scope).close(); + assertEquals(FeatureResourceState.CLOSED, manager.state()); assertEquals(0, manager.getActiveConnCount()); + assertTrue(manager.connectionIdentifiers().isEmpty()); } @Test @@ -94,6 +105,9 @@ void ormContextsUseTheBoundApiAndRelationalDataSource() { assertTrue(manager.registerConnection("main", DatabaseType.MYSQL, "default").isPresent()); assertSame(ormContext, manager.createORMContext("main", String.class).orElseThrow()); + assertTrue(manager.hasOrmContext("main")); + assertEquals(Set.of("main"), manager.ormContextIdentifiers()); + assertEquals(1, manager.activeOrmContextCount()); manager.closeAllConnections(); verify(api).createOrmContext(same(dataSource), any(), eq("validate"), eq(String.class)); diff --git a/featureframework-dataregistry/src/main/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerIdentityResolver.java b/featureframework-dataregistry/src/main/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerIdentityResolver.java index 6aed121..ea82d20 100644 --- a/featureframework-dataregistry/src/main/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerIdentityResolver.java +++ b/featureframework-dataregistry/src/main/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerIdentityResolver.java @@ -92,6 +92,17 @@ public Optional findActiveByUuid(String uuid) { : playerDirectory.findActiveIdentityCached(normalized); } + /** Looks up an active identity by either UUID or current username without performing I/O. */ + public Optional findActiveByIdentifier(String identifier) { + String normalized = normalize(identifier); + if (normalized == null) return Optional.empty(); + try { + return findActiveByUuid(UUID.fromString(normalized)); + } catch (IllegalArgumentException ignored) { + return findActiveByUsername(normalized); + } + } + /** * Looks up an active identity by its current username without performing I/O. */ @@ -105,14 +116,6 @@ public Optional findActiveByUsername(String username) { .findFirst(); } - private Optional findActiveByIdentifier(String identifier) { - try { - return findActiveByUuid(UUID.fromString(identifier)); - } catch (IllegalArgumentException ignored) { - return findActiveByUsername(identifier); - } - } - /** * Waits for platform identity preparation and then performs a persisted lookup when readiness * completes empty. DataRegistry readiness is intentionally cache/lifecycle-only, so the fallback diff --git a/featureframework-dataregistry/src/main/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerReference.java b/featureframework-dataregistry/src/main/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerReference.java index 81a1f0b..417c245 100644 --- a/featureframework-dataregistry/src/main/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerReference.java +++ b/featureframework-dataregistry/src/main/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerReference.java @@ -3,6 +3,7 @@ import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; import java.util.Objects; +import java.util.Optional; /** * Immutable, persistence-free reference to a DataRegistry player. @@ -28,6 +29,11 @@ public static PlayerReference byId(long playerId) { return new PlayerReference(playerId, null, null); } + public long playerId() { return id; } + public Optional uuidOptional() { return Optional.ofNullable(uuid); } + public Optional usernameOptional() { return Optional.ofNullable(username); } + public boolean hasIdentitySnapshot() { return uuid != null || username != null; } + /** * References identify the same player when their canonical DataRegistry ids match. Snapshot fields * must not participate: persistence intentionally restores an id-only reference. diff --git a/featureframework-dataregistry/src/main/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerReferenceResolver.java b/featureframework-dataregistry/src/main/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerReferenceResolver.java index 9c500c9..968ba2a 100644 --- a/featureframework-dataregistry/src/main/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerReferenceResolver.java +++ b/featureframework-dataregistry/src/main/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerReferenceResolver.java @@ -40,10 +40,18 @@ public Optional findByUuid(String uuid) { return findIdentityByUuid(uuid).map(PlayerReference::from); } + public Optional findByUsername(String username) { + return findIdentityByUsername(username).map(PlayerReference::from); + } + public Optional findByIdentifier(String identifier) { return findIdentityByIdentifier(identifier).map(PlayerReference::from); } + public Optional findById(Long playerId) { + return findIdentityById(playerId).map(PlayerReference::from); + } + public CompletionStage> findByUuidAsync(UUID uuid) { return findIdentityByUuidAsync(uuid).thenApply(identity -> identity.map(PlayerReference::from)); } @@ -52,10 +60,18 @@ public CompletionStage> findByUuidAsync(String uuid) { return findIdentityByUuidAsync(uuid).thenApply(identity -> identity.map(PlayerReference::from)); } + public CompletionStage> findByUsernameAsync(String username) { + return findIdentityByUsernameAsync(username).thenApply(identity -> identity.map(PlayerReference::from)); + } + public CompletionStage> findByIdentifierAsync(String identifier) { return findIdentityByIdentifierAsync(identifier).thenApply(identity -> identity.map(PlayerReference::from)); } + public CompletionStage> findByIdAsync(Long playerId) { + return findIdentityByIdAsync(playerId).thenApply(identity -> identity.map(PlayerReference::from)); + } + public CompletionStage> findByIdsAsync(Collection playerIds) { if (playerIds == null || playerIds.isEmpty()) { return CompletableFuture.completedFuture(List.of()); @@ -217,7 +233,7 @@ public CompletionStage> findPersistedIdentityByIdentifi } public CompletableFuture> whenReady(UUID uuid) { - return playerDirectory.whenReady(uuid); + return uuid == null ? CompletableFuture.completedFuture(Optional.empty()) : playerDirectory.whenReady(uuid); } public PlayerReference resolveReference(UUID uuid) { diff --git a/featureframework-dataregistry/src/test/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerReferenceConverterTest.java b/featureframework-dataregistry/src/test/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerReferenceConverterTest.java index 58f8d50..9fbfcc7 100644 --- a/featureframework-dataregistry/src/test/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerReferenceConverterTest.java +++ b/featureframework-dataregistry/src/test/java/nl/hauntedmc/featureframework/integration/dataregistry/PlayerReferenceConverterTest.java @@ -12,10 +12,17 @@ class PlayerReferenceConverterTest { void roundTripPreservesCanonicalIdentityButNotTransientSnapshots() { PlayerReference reference = new PlayerReference(42L, "7d73c2d0-a9a7-4acb-a4e6-cc9ad0ef19c3", "Remy"); + assertEquals(42L, reference.playerId()); + assertEquals("Remy", reference.usernameOptional().orElseThrow()); + assertTrue(reference.uuidOptional().isPresent()); + assertTrue(reference.hasIdentitySnapshot()); assertEquals(42L, converter.convertToDatabaseColumn(reference)); PlayerReference restored = converter.convertToEntityAttribute(42L); assertEquals(reference, restored); + assertFalse(restored.hasIdentitySnapshot()); + assertTrue(restored.uuidOptional().isEmpty()); + assertTrue(restored.usernameOptional().isEmpty()); assertNull(restored.uuid()); assertNull(restored.username()); } diff --git a/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java b/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java index 76bd24d..a46dad6 100644 --- a/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java +++ b/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java @@ -239,6 +239,12 @@ public Builder afterHostResourcesReload(Runnable value) { public Builder contribute(FeatureResourceContributor value) { contributors.add(Objects.requireNonNull(value, "contributor")); return this; } + public Builder contributors( + Iterable> values + ) { + Objects.requireNonNull(values, "contributors").forEach(this::contribute); + return this; + } public Builder bootstrapCapability(Class type, T value) { bootstrapCapabilities.add(new BootstrapCapability<>(type, type.cast(value))); return this; } diff --git a/featureframework-testkit/src/main/java/nl/hauntedmc/featureframework/test/InterfaceProxy.java b/featureframework-testkit/src/main/java/nl/hauntedmc/featureframework/test/InterfaceProxy.java index 87dd116..7fcf9dd 100644 --- a/featureframework-testkit/src/main/java/nl/hauntedmc/featureframework/test/InterfaceProxy.java +++ b/featureframework-testkit/src/main/java/nl/hauntedmc/featureframework/test/InterfaceProxy.java @@ -12,9 +12,16 @@ public final class InterfaceProxy { private InterfaceProxy() { } + public static T of(Class type) { + return of(type, Map.of()); + } + public static T of(Class type, Map> handlers) { Objects.requireNonNull(type, "type"); Objects.requireNonNull(handlers, "handlers"); + if (!type.isInterface()) { + throw new IllegalArgumentException("Proxy type must be an interface: " + type.getName()); + } InvocationHandler invocationHandler = (proxy, method, args) -> { if (method.getDeclaringClass() == Object.class) { diff --git a/featureframework-testkit/src/main/java/nl/hauntedmc/featureframework/test/TestFs.java b/featureframework-testkit/src/main/java/nl/hauntedmc/featureframework/test/TestFs.java index 5606df9..30e2343 100644 --- a/featureframework-testkit/src/main/java/nl/hauntedmc/featureframework/test/TestFs.java +++ b/featureframework-testkit/src/main/java/nl/hauntedmc/featureframework/test/TestFs.java @@ -11,6 +11,14 @@ public final class TestFs { private TestFs() { } + public static Path directory(Path directory) { + try { + return Files.createDirectories(directory); + } catch (IOException e) { + throw new RuntimeException("Could not create test directory: " + directory, e); + } + } + public static Path touch(Path file) { try { Files.createDirectories(file.getParent()); @@ -19,7 +27,7 @@ public static Path touch(Path file) { } return file; } catch (IOException e) { - throw new RuntimeException("Kon testbestand niet aanmaken: " + file, e); + throw new RuntimeException("Could not create test file: " + file, e); } } @@ -29,7 +37,15 @@ public static Path write(Path file, String content) { return Files.writeString(file, content == null ? "" : content, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } catch (IOException e) { - throw new RuntimeException("Kon testbestand niet schrijven: " + file, e); + throw new RuntimeException("Could not write test file: " + file, e); + } + } + + public static String read(Path file) { + try { + return Files.readString(file, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException("Could not read test file: " + file, e); } } diff --git a/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/Theme.java b/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/Theme.java index 3e80ec2..3658bbc 100644 --- a/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/Theme.java +++ b/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/Theme.java @@ -13,6 +13,7 @@ public final class Theme { private final ThemeId id; private final Map itemsByKey; + private final List items; private Theme(Builder builder) { id = builder.id; @@ -20,6 +21,7 @@ private Theme(Builder builder) { throw new IllegalArgumentException("theme must contain at least one item"); } itemsByKey = Collections.unmodifiableMap(new LinkedHashMap<>(builder.itemsByKey)); + items = List.copyOf(itemsByKey.values()); } public static Builder builder(String id) { @@ -35,7 +37,11 @@ public ThemeId id() { } public List items() { - return List.copyOf(itemsByKey.values()); + return items; + } + + public int size() { + return items.size(); } public Optional item(String itemId) { @@ -61,14 +67,23 @@ public Builder item(String itemId, ThemeColor color) { } public Builder item(ThemeItemId itemId, ThemeColor color) { - ThemeItem item = new ThemeItem(itemId, color); - ThemeItem previous = itemsByKey.putIfAbsent(itemId.lookupKey(), item); + return item(new ThemeItem(itemId, color)); + } + + public Builder item(ThemeItem item) { + ThemeItem value = Objects.requireNonNull(item, "item"); + ThemeItem previous = itemsByKey.putIfAbsent(value.id().lookupKey(), value); if (previous != null) { - throw new IllegalArgumentException("duplicate theme item identifier: " + itemId.value()); + throw new IllegalArgumentException("duplicate theme item identifier: " + value.id().value()); } return this; } + public Builder items(Iterable values) { + Objects.requireNonNull(values, "items").forEach(this::item); + return this; + } + public Builder solid(String itemId, TextColor color) { return item(itemId, ThemeColor.solid(color)); } diff --git a/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/ThemeId.java b/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/ThemeId.java index 85c77d0..23c7d70 100644 --- a/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/ThemeId.java +++ b/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/ThemeId.java @@ -2,6 +2,7 @@ import java.util.Locale; import java.util.Objects; +import java.util.Optional; import java.util.regex.Pattern; /** Validated identifier for a registered theme. */ @@ -16,16 +17,30 @@ public static ThemeId of(String value) { return new ThemeId(value); } + /** Returns whether a raw value can be normalized into a valid theme identifier. */ + public static boolean isValid(String value) { + return value != null && isNormalizedValueValid(value.trim()); + } + + /** Parses a theme identifier without throwing for null or malformed external input. */ + public static Optional tryParse(String value) { + return isValid(value) ? Optional.of(new ThemeId(value)) : Optional.empty(); + } + String lookupKey() { return value.toLowerCase(Locale.ROOT); } static String validate(String value, String description) { String candidate = Objects.requireNonNull(value, description).trim(); - if (!VALID_IDENTIFIER.matcher(candidate).matches()) { + if (!isNormalizedValueValid(candidate)) { throw new IllegalArgumentException(description + " must start with an alphanumeric character and contain only alphanumerics, '_' or '-'"); } return candidate; } + + static boolean isNormalizedValueValid(String value) { + return VALID_IDENTIFIER.matcher(value).matches(); + } } diff --git a/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/ThemeItemId.java b/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/ThemeItemId.java index 3533c98..d3bc622 100644 --- a/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/ThemeItemId.java +++ b/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/ThemeItemId.java @@ -1,6 +1,7 @@ package nl.hauntedmc.featureframework.theme; import java.util.Locale; +import java.util.Optional; /** Validated identifier for one colour item inside a theme. */ public record ThemeItemId(String value) { @@ -12,6 +13,16 @@ public static ThemeItemId of(String value) { return new ThemeItemId(value); } + /** Returns whether a raw value can be normalized into a valid theme item identifier. */ + public static boolean isValid(String value) { + return value != null && ThemeId.isNormalizedValueValid(value.trim()); + } + + /** Parses a theme item identifier without throwing for null or malformed external input. */ + public static Optional tryParse(String value) { + return isValid(value) ? Optional.of(new ThemeItemId(value)) : Optional.empty(); + } + String lookupKey() { return value.toLowerCase(Locale.ROOT); } diff --git a/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/ThemeRegistry.java b/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/ThemeRegistry.java index d783141..debc4a0 100644 --- a/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/ThemeRegistry.java +++ b/featureframework-theme-api/src/main/java/nl/hauntedmc/featureframework/theme/ThemeRegistry.java @@ -13,9 +13,11 @@ public final class ThemeRegistry { private static final ThemeRegistry EMPTY = new ThemeRegistry(Map.of()); private final Map themesByKey; + private final List themes; private ThemeRegistry(Map themesByKey) { this.themesByKey = Collections.unmodifiableMap(new LinkedHashMap<>(themesByKey)); + themes = List.copyOf(this.themesByKey.values()); } public static ThemeRegistry empty() { @@ -44,8 +46,12 @@ public boolean isEmpty() { return themesByKey.isEmpty(); } + public int size() { + return themes.size(); + } + public List themes() { - return List.copyOf(themesByKey.values()); + return themes; } public Optional theme(String id) { @@ -74,6 +80,11 @@ public Builder themes(Iterable values) { return this; } + public Builder include(ThemeRegistry registry) { + themes(Objects.requireNonNull(registry, "registry").themes()); + return this; + } + public ThemeRegistry build() { return ThemeRegistry.of(themes); } diff --git a/featureframework-theme-api/src/test/java/nl/hauntedmc/featureframework/theme/ThemeTest.java b/featureframework-theme-api/src/test/java/nl/hauntedmc/featureframework/theme/ThemeTest.java index 861e1a3..70cffdc 100644 --- a/featureframework-theme-api/src/test/java/nl/hauntedmc/featureframework/theme/ThemeTest.java +++ b/featureframework-theme-api/src/test/java/nl/hauntedmc/featureframework/theme/ThemeTest.java @@ -4,8 +4,11 @@ import org.junit.jupiter.api.Test; import java.util.List; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -18,13 +21,36 @@ void buildsImmutableCaseInsensitiveThemeRegistry() { .build(); ThemeRegistry registry = ThemeRegistry.of(List.of(haunted)); ThemeRegistry builtRegistry = ThemeRegistry.builder().theme(haunted).build(); + ThemeRegistry includedRegistry = ThemeRegistry.builder().include(registry).build(); + Theme copied = Theme.builder("Copied").items(haunted.items()).build(); assertEquals(0xA855F7, ((ThemeColor.Solid) registry.item("hauntedmc", "BRAND") .orElseThrow().color()).color().value()); assertEquals(List.of("Brand", "Header"), haunted.items().stream() .map(item -> item.id().value()).toList()); + assertEquals(2, haunted.size()); + assertEquals(2, copied.size()); + assertEquals(1, registry.size()); + assertSame(haunted.items(), haunted.items()); + assertSame(registry.themes(), registry.themes()); assertTrue(registry.theme("HAUNTEDMC").isPresent()); assertTrue(builtRegistry.theme("hauntedmc").isPresent()); + assertTrue(includedRegistry.theme("hauntedmc").isPresent()); + } + + @Test + void identifiersSupportExceptionFreeExternalValidation() { + assertTrue(ThemeId.isValid(" HauntedMC ")); + assertFalse(ThemeId.isValid("bad:id")); + assertFalse(ThemeId.isValid(null)); + assertEquals(Optional.of(ThemeId.of("HauntedMC")), ThemeId.tryParse(" HauntedMC ")); + assertTrue(ThemeId.tryParse("bad:id").isEmpty()); + + assertTrue(ThemeItemId.isValid(" Brand ")); + assertFalse(ThemeItemId.isValid("bad:item")); + assertFalse(ThemeItemId.isValid(null)); + assertEquals(Optional.of(ThemeItemId.of("Brand")), ThemeItemId.tryParse(" Brand ")); + assertTrue(ThemeItemId.tryParse("bad:item").isEmpty()); } @Test diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/http/AsyncHttpTransport.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/http/AsyncHttpTransport.java index e854d92..28b24cf 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/http/AsyncHttpTransport.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/http/AsyncHttpTransport.java @@ -7,4 +7,8 @@ @FunctionalInterface public interface AsyncHttpTransport { CompletionStage post(URI uri, String contentType, String body, boolean requireHttps); + + default CompletionStage postJson(URI uri, String body, boolean requireHttps) { + return post(uri, "application/json", body, requireHttps); + } } diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/http/HttpResponseData.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/http/HttpResponseData.java index 018c15f..97de517 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/http/HttpResponseData.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/http/HttpResponseData.java @@ -13,4 +13,20 @@ public record HttpResponseData(int statusCode, URI uri, String body) { public boolean successful() { return statusCode >= 200 && statusCode < 300; } + + public boolean redirect() { + return statusCode >= 300 && statusCode < 400; + } + + public boolean clientError() { + return statusCode >= 400 && statusCode < 500; + } + + public boolean serverError() { + return statusCode >= 500 && statusCode < 600; + } + + public boolean error() { + return clientError() || serverError(); + } } diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/cache/CacheValue.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/cache/CacheValue.java index 70d620a..652eeba 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/cache/CacheValue.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/cache/CacheValue.java @@ -19,8 +19,19 @@ public static CacheValue of(Map data, long expirationTimestamp) } public Map getData() { return data; } + public Object get(String key) { return data.get(key); } + public T get(String key, Class type) { + Object value = data.get(key); + return value == null ? null : Objects.requireNonNull(type, "type").cast(value); + } + public boolean contains(String key) { return data.containsKey(key); } + public int size() { return data.size(); } + public boolean isEmpty() { return data.isEmpty(); } public long getExpirationTimestamp() { return expirationTimestamp; } - public boolean isExpired() { return expirationTimestamp > 0 && System.currentTimeMillis() > expirationTimestamp; } + public boolean isExpired() { return isExpired(System.currentTimeMillis()); } + public boolean isExpired(long nowMillis) { + return expirationTimestamp > 0 && nowMillis > expirationTimestamp; + } public static Builder builder(long ttlMillis) { return new Builder(ttlMillis); } public static final class Builder { @@ -37,6 +48,11 @@ public Builder with(String key, Object value) { return this; } + public Builder withAll(Map values) { + Objects.requireNonNull(values, "values").forEach(this::with); + return this; + } + public CacheValue build() { return new CacheValue(Map.copyOf(data), System.currentTimeMillis() + ttlMillis); } diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/cache/FileCacheStore.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/cache/FileCacheStore.java index d532903..02488db 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/cache/FileCacheStore.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/cache/FileCacheStore.java @@ -1,11 +1,14 @@ package nl.hauntedmc.featureframework.toolkit.io.cache; import java.util.Map; +import java.util.Optional; /** On-disk cache store with one {@link CacheValue} per key. */ public interface FileCacheStore extends CacheStore { void put(String key, CacheValue value); CacheValue get(String key); + default Optional getOptional(String key) { return Optional.ofNullable(get(key)); } + default boolean contains(String key) { return get(key) != null; } void remove(String key); Map listAll(); Map find(String regex); diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/cache/impl/JsonCacheFile.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/cache/impl/JsonCacheFile.java index 77583d9..6791698 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/cache/impl/JsonCacheFile.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/cache/impl/JsonCacheFile.java @@ -389,7 +389,7 @@ private static boolean isExpired(Map entry, long now) { return false; } long timestamp = number.longValue(); - return timestamp >= 0 && now > timestamp; + return timestamp > 0 && now > timestamp; } private static CacheValue toValue(Map entry) { diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigMap.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigMap.java index eb25bcc..6f3b356 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigMap.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigMap.java @@ -14,6 +14,12 @@ public ConfigMap put(String key, Object value) { return this; } + /** Adds all supplied values and returns this map for fluent default construction. */ + public ConfigMap putAll(Map values) { + this.values.putAll(values); + return this; + } + public Object get(String key) { return values.get(key); } @@ -29,6 +35,8 @@ public T get(String key, Class type) { } public boolean contains(String key) { return values.containsKey(key); } + public boolean isEmpty() { return values.isEmpty(); } + public int size() { return values.size(); } public Set keySet() { return values.keySet(); } public Set> entrySet() { return values.entrySet(); } public Map toMap() { return new HashMap<>(values); } diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigNode.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigNode.java index 30d4bc2..12317de 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigNode.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigNode.java @@ -18,8 +18,19 @@ public static ConfigNode ofRaw(Object raw, String path) { public boolean isNull() { return value == null; } public boolean isPresent() { return !isNull(); } + public boolean isMap() { return value instanceof Map; } + public boolean isList() { return value instanceof List; } + public int size() { + if (value instanceof Map map) return map.size(); + if (value instanceof List list) return list.size(); + return 0; + } public T as(Class type, T defaultValue) { return ConfigTypes.convertOrDefault(value, type, defaultValue); } + public Optional asOptional(Class type) { + return Optional.ofNullable(ConfigTypes.convert(value, type)); + } + public T asRequired(Class type) { T converted = ConfigTypes.convert(value, type); if (converted == null) throw new IllegalStateException("Required config missing at '" + path + "'"); diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigService.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigService.java index c7758fa..2eef8f5 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigService.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigService.java @@ -10,9 +10,9 @@ import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.PosixFilePermission; -import java.util.Set; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** Central registry/factory for YAML files shared by the Paper runtime and feature implementations. */ @@ -80,6 +80,9 @@ public Optional openExisting(String relativePath) { } public boolean exists(String relativePath) { return Files.exists(resolve(relativePath)); } + public boolean isCached(String relativePath) { return cache.containsKey(resolve(relativePath)); } + public int cachedFileCount() { return cache.size(); } + public Set cachedPaths() { return Set.copyOf(cache.keySet()); } /** Root directory used by this service. Administrative storage operations must remain below it. */ public Path dataDirectory() { return dataDir; } diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigView.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigView.java index d41b0d7..b7b5dd2 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigView.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigView.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.function.Supplier; @@ -32,6 +33,10 @@ protected String base(String key) { public Object get(String key) { return file.getRaw(base(key)); } public T get(String key, Class type) { return ConfigTypes.convert(get(key), type); } public T get(String key, Class type, T def) { return ConfigTypes.convertOrDefault(get(key), type, def); } + public Optional getOptional(String key, Class type) { + return Optional.ofNullable(get(key, type)); + } + public boolean contains(String key) { return node(key).isPresent(); } public List getList(String key, Class type) { return ConfigTypes.convertList(get(key), type); } public List getList(String key, Class type, List def) { @@ -61,10 +66,20 @@ public Map getMapValues(String key, Class type, Map public ConfigNode nodeAt(String path) { return node().getAt(path); } public T getAt(String path, Class type) { return node().getAt(path).asRequired(type); } public T getAt(String path, Class type, T def) { return node().getAt(path).as(type, def); } + public Optional getAtOptional(String path, Class type) { + return node().getAt(path).asOptional(type); + } public void put(String path, Object value) { file.setRawAndSave(base(path), value); } public void remove(String path) { put(path, null); } + /** Applies multiple path/value updates in one atomic file write. */ + public void putAll(Map values) { + Map required = Objects.requireNonNull(values, "values"); + if (required.isEmpty()) return; + batch(batch -> batch.putAll(required)); + } + public boolean putIfAbsent(String path, Object value) { String absolute = base(path); file.lock().writeLock().lock(); @@ -188,6 +203,11 @@ public Batch put(String path, Object value) { return this; } + public Batch putAll(Map values) { + Objects.requireNonNull(values, "values").forEach(this::put); + return this; + } + public Batch putIfAbsent(String path, Object value) { CommentedConfigurationNode node = root.node(YamlFile.splitPath(base(path))); if (node.virtual()) { diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/YamlFile.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/YamlFile.java index 770af19..d7756d3 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/YamlFile.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/YamlFile.java @@ -12,8 +12,9 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.PosixFilePermission; -import java.util.Set; import java.util.Objects; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Consumer; @@ -41,6 +42,9 @@ public YamlFile(Path path, org.slf4j.Logger logger) { this(path, FrameworkLogger.from(logger)); } + public Path path() { return path; } + public Optional loadFailure() { return Optional.ofNullable(loadFailure); } + public boolean hasLoadFailure() { return loadFailure != null; } public ReentrantReadWriteLock lock() { return lock; } public void reload() { diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/localization/Language.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/localization/Language.java index 3873b62..013031a 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/localization/Language.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/localization/Language.java @@ -2,6 +2,7 @@ import java.util.Arrays; import java.util.List; +import java.util.Optional; /** Supported framework localization languages. */ public enum Language { @@ -9,6 +10,10 @@ public enum Language { NL("NL", true), EN("EN", true); + private static final List LOCALIZABLE_VALUES = Arrays.stream(values()) + .filter(Language::isLocalizable) + .toList(); + private final String code; private final boolean localizable; @@ -17,9 +22,21 @@ public enum Language { this.localizable = localizable; } + public String code() { return code; } public String getFileName() { return "messages_" + code + ".yml"; } public boolean isLocalizable() { return localizable; } + + /** Resolves a language code case-insensitively without throwing for external input. */ + public static Optional fromCode(String code) { + if (code == null) return Optional.empty(); + String normalized = code.trim(); + if (normalized.isEmpty()) return Optional.empty(); + return Arrays.stream(values()) + .filter(language -> language.code.equalsIgnoreCase(normalized)) + .findFirst(); + } + public static List localizableValues() { - return Arrays.stream(values()).filter(Language::isLocalizable).toList(); + return LOCALIZABLE_VALUES; } } diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/localization/MessageMap.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/localization/MessageMap.java index c7101ff..ddc9848 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/localization/MessageMap.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/localization/MessageMap.java @@ -6,6 +6,23 @@ /** Ordered message defaults used by feature localization files. */ public class MessageMap { private final Map messages = new LinkedHashMap<>(); + public void add(String key, String defaultValue) { messages.put(key, defaultValue); } + + /** Fluent equivalent of {@link #add(String, String)} for programmatic default construction. */ + public MessageMap put(String key, String defaultValue) { + add(key, defaultValue); + return this; + } + + /** Adds an ordered batch of message defaults. */ + public MessageMap putAll(Map values) { + messages.putAll(values); + return this; + } + + public boolean contains(String key) { return messages.containsKey(key); } + public boolean isEmpty() { return messages.isEmpty(); } + public int size() { return messages.size(); } public Map getMessages() { return Map.copyOf(messages); } } diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/pagination/Paginator.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/pagination/Paginator.java index 26cd9d4..7576d10 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/pagination/Paginator.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/pagination/Paginator.java @@ -7,7 +7,14 @@ public final class Paginator { private Paginator() { } - public record Page(List items, int page, int totalPages, int totalItems, int pageSize) { } + public record Page(List items, int page, int totalPages, int totalItems, int pageSize) { + public boolean hasPrevious() { return page > 1; } + public boolean hasNext() { return page < totalPages; } + public boolean isFirst() { return page == 1; } + public boolean isLast() { return page == totalPages; } + public int previousPage() { return Math.max(1, page - 1); } + public int nextPage() { return Math.min(totalPages, page + 1); } + } public static Page paginate(List all, int page, int pageSize) { if (pageSize <= 0) throw new IllegalArgumentException("pageSize must be positive"); diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/text/placeholder/MessagePlaceholders.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/text/placeholder/MessagePlaceholders.java index fa8c468..d2963d2 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/text/placeholder/MessagePlaceholders.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/text/placeholder/MessagePlaceholders.java @@ -7,6 +7,8 @@ /** Immutable typed placeholder bag used by framework and feature messages. */ public final class MessagePlaceholders { + private static final MessagePlaceholders EMPTY = new MessagePlaceholders(Map.of()); + private final Map values; private MessagePlaceholders(Map values) { @@ -14,10 +16,15 @@ private MessagePlaceholders(Map values) { } public static Builder builder() { return new Builder(); } - public static MessagePlaceholders empty() { return new MessagePlaceholders(Map.of()); } + public static MessagePlaceholders empty() { return EMPTY; } public static MessagePlaceholders of(String key, String value) { return new MessagePlaceholders(Map.of(key, value)); } public static MessagePlaceholders of(Map values) { return new MessagePlaceholders(new HashMap<>(values)); } public String get(String key) { return values.get(key); } + public boolean contains(String key) { return values.containsKey(key); } + public int size() { return values.size(); } + public boolean isEmpty() { return values.isEmpty(); } + public Map asMap() { return values; } + public String apply(String message) { return applyPlaceholders(message, this); } public static String applyPlaceholders(String message, MessagePlaceholders placeholders) { if (message == null || placeholders == null || placeholders.values.isEmpty()) return message; diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/token/TokenResult.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/token/TokenResult.java index feb6618..616c6f5 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/token/TokenResult.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/token/TokenResult.java @@ -1,6 +1,7 @@ package nl.hauntedmc.featureframework.toolkit.token; import java.util.Objects; +import java.util.Optional; /** * Result of consuming a token. @@ -47,7 +48,16 @@ public State state() { return state; } + public boolean isOk() { + return state == State.OK; + } + public T payload() { return payload; } + + /** Returns the payload as an Optional for callers that prefer null-free result handling. */ + public Optional payloadOptional() { + return Optional.ofNullable(payload); + } } diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/token/TokenService.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/token/TokenService.java index db028bd..4ef70ee 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/token/TokenService.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/token/TokenService.java @@ -4,16 +4,16 @@ import java.time.Duration; import java.util.Base64; import java.util.Objects; -import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; /** * Generic, thread-safe token service. * - Tokens map to a payload that may be loaded asynchronously. * - Supports max uses, expiration, optional consume-on-empty. - * - No external scheduler assumptions: caller supplies a loader that returns a - * {@code CompletableFuture}. + * - No external scheduler assumptions: caller supplies a loader that returns a completion stage. *

* Typical flow: * String token = service.create(() -> myAsyncLoader(), options); @@ -49,14 +49,19 @@ private static final class Entry { } } + /** Creates an unlimited token using the supplied asynchronous payload loader. */ + public String create(Supplier> payloadLoader) { + return create(payloadLoader, TokenOptions.infinite()); + } + /** * Create a token and start loading the payload with the provided loader. * The loader should complete exceptionally or with null to represent "empty" payload. * - * @param payloadLoader supplier returning a {@code CompletableFuture} (can load sync or async) + * @param payloadLoader supplier returning a completion stage (can load sync or async) * @param options token options (uses/expiry) */ - public String create(java.util.function.Supplier> payloadLoader, TokenOptions options) { + public String create(Supplier> payloadLoader, TokenOptions options) { Objects.requireNonNull(payloadLoader, "payloadLoader"); Objects.requireNonNull(options, "options"); @@ -70,24 +75,24 @@ public String create(java.util.function.Supplier> payloadLo final Entry entry = new Entry<>(expiresAt, initialUses, true, options.consumeOnEmpty()); store.put(token, entry); - CompletableFuture fut; + CompletionStage future; try { - fut = payloadLoader.get(); + future = payloadLoader.get(); } catch (Throwable t) { - // loader threw before returning CF => treat as empty + // loader threw before returning a stage => treat as empty entry.payload = null; entry.loading = false; return token; } - if (fut == null) { + if (future == null) { entry.payload = null; entry.loading = false; return token; } - fut.handle((res, err) -> { - entry.payload = (err != null) ? null : res; + future.handle((res, err) -> { + entry.payload = err != null ? null : res; entry.loading = false; return null; }); @@ -142,10 +147,28 @@ public void revoke(String token) { if (token != null) store.remove(token); } + public void clear() { + store.clear(); + } + + /** Removes expired or exhausted tokens immediately instead of waiting for opportunistic cleanup. */ + public void cleanup() { + cleanup(System.currentTimeMillis()); + lastCleanup = System.currentTimeMillis(); + } + + public String namespace() { + return namespace; + } + public int size() { return store.size(); } + public boolean isEmpty() { + return store.isEmpty(); + } + private String newToken() { final byte[] buf = new byte[TOKEN_BYTES]; String token; @@ -160,9 +183,13 @@ private void cleanupIfNeeded() { final long now = System.currentTimeMillis(); if (now - lastCleanup < CLEANUP_INTERVAL_MILLIS && store.size() < 500) return; lastCleanup = now; - store.forEach((k, v) -> { - if (now > v.expiresAt || v.usesLeft.get() == 0) { - store.remove(k); + cleanup(now); + } + + private void cleanup(long now) { + store.forEach((key, value) -> { + if (now > value.expiresAt || value.usesLeft.get() == 0) { + store.remove(key, value); } }); } diff --git a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/http/HttpResponseDataTest.java b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/http/HttpResponseDataTest.java new file mode 100644 index 0000000..9e4c6f9 --- /dev/null +++ b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/http/HttpResponseDataTest.java @@ -0,0 +1,40 @@ +package nl.hauntedmc.featureframework.toolkit.http; + +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; + +class HttpResponseDataTest { + @Test + void statusHelpersClassifyCommonHttpResponses() { + HttpResponseData ok = new HttpResponseData(204, URI.create("https://example.com"), null); + HttpResponseData redirect = new HttpResponseData(302, URI.create("https://example.com"), ""); + HttpResponseData clientError = new HttpResponseData(404, URI.create("https://example.com"), "missing"); + HttpResponseData serverError = new HttpResponseData(503, URI.create("https://example.com"), "down"); + + assertTrue(ok.successful()); + assertEquals("", ok.body()); + assertTrue(redirect.redirect()); + assertTrue(clientError.clientError()); + assertTrue(clientError.error()); + assertTrue(serverError.serverError()); + assertTrue(serverError.error()); + assertFalse(serverError.successful()); + } + + @Test + void transportProvidesJsonConvenienceWithoutChangingFunctionalContract() { + AsyncHttpTransport transport = (uri, contentType, body, requireHttps) -> { + assertEquals("application/json", contentType); + assertTrue(requireHttps); + return CompletableFuture.completedFuture(new HttpResponseData(200, uri, body)); + }; + + HttpResponseData response = transport.postJson(URI.create("https://example.com"), "{}", true) + .toCompletableFuture().join(); + assertEquals("{}", response.body()); + } +} diff --git a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/cache/CacheValueTest.java b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/cache/CacheValueTest.java index 64dcb88..71ac17d 100644 --- a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/cache/CacheValueTest.java +++ b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/cache/CacheValueTest.java @@ -10,12 +10,20 @@ class CacheValueTest { @Test void builderCreatesImmutableValuesAndExpirationIsObserved() { assertThrows(IllegalArgumentException.class, () -> CacheValue.builder(-1)); - CacheValue built = CacheValue.builder(50).with("name", "server").with("score", 7).build(); - assertEquals("server", built.getData().get("name")); + CacheValue built = CacheValue.builder(50) + .with("name", "server") + .withAll(Map.of("score", 7)) + .build(); + assertEquals("server", built.get("name")); + assertEquals(7, built.get("score", Integer.class)); + assertTrue(built.contains("name")); + assertEquals(2, built.size()); + assertFalse(built.isEmpty()); assertThrows(UnsupportedOperationException.class, () -> built.getData().put("x", "y")); long now = System.currentTimeMillis(); - assertTrue(CacheValue.of(Map.of("a", 1), now - 1).isExpired()); - assertFalse(CacheValue.of(Map.of("a", 1), now + 60_000).isExpired()); + assertTrue(CacheValue.of(Map.of("a", 1), now - 1).isExpired(now)); + assertFalse(CacheValue.of(Map.of("a", 1), now + 60_000).isExpired(now)); + assertFalse(CacheValue.of(Map.of("a", 1), 0).isExpired(now)); assertThrows(NullPointerException.class, () -> CacheValue.of(null, 0)); assertThrows(NullPointerException.class, () -> CacheValue.builder(1).with(null, "x")); } diff --git a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/cache/JsonCacheFileTest.java b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/cache/JsonCacheFileTest.java index 6779e84..641d6bc 100644 --- a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/cache/JsonCacheFileTest.java +++ b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/cache/JsonCacheFileTest.java @@ -41,6 +41,21 @@ void valuesPersistReloadAndRegexLookupWorks() { assertFalse(reloaded.isEmpty()); } + @Test + void nonPositiveExpirationRemainsNonExpiringAfterPersistence() { + Path file = tempDir.resolve("never-expires.json"); + JsonCacheFile cache = new JsonCacheFile(file.toFile()); + cache.put("zero", CacheValue.of(Map.of("value", 1), 0)); + cache.put("negative", CacheValue.of(Map.of("value", 2), -1)); + + JsonCacheFile reloaded = new JsonCacheFile(file.toFile()); + + assertNotNull(reloaded.get("zero")); + assertNotNull(reloaded.get("negative")); + assertEquals(2, reloaded.listAll().size()); + assertTrue(Files.exists(file)); + } + @Test void expiredEntriesAreRemovedAndEmptyStoreDeletesFile() { Path file = tempDir.resolve("expired.json"); diff --git a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigMapTest.java b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigMapTest.java index 2420283..eabf8aa 100644 --- a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigMapTest.java +++ b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigMapTest.java @@ -10,11 +10,15 @@ class ConfigMapTest { @Test void putGetTypedGetAndCollectionViewsWork() { - ConfigMap map = new ConfigMap().put("name", "server").put("enabled", true).put("count", 3); + ConfigMap map = new ConfigMap() + .put("name", "server") + .putAll(Map.of("enabled", true, "count", 3)); assertEquals("server", map.get("name")); assertEquals("server", map.get("name", String.class)); assertNull(map.get("missing", String.class)); assertTrue(map.contains("enabled")); + assertFalse(map.isEmpty()); + assertEquals(3, map.size()); assertTrue(map.keySet().contains("count")); assertTrue(map.entrySet().stream().anyMatch(entry -> entry.getKey().equals("name"))); Map copy = map.toMap(); @@ -24,6 +28,7 @@ void putGetTypedGetAndCollectionViewsWork() { map.forEach((key, value) -> seen.incrementAndGet()); assertEquals(3, seen.get()); assertTrue(map.toString().contains("server")); + assertTrue(new ConfigMap().isEmpty()); } @Test diff --git a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigNodeTest.java b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigNodeTest.java index 789fdac..f76eb1f 100644 --- a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigNodeTest.java +++ b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigNodeTest.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -16,8 +17,14 @@ void traversalAndTypedChildViewsWork() { "weights", Map.of("a", "1") ), "root"); assertFalse(root.isNull()); + assertTrue(root.isMap()); + assertFalse(root.isList()); + assertEquals(3, root.size()); assertEquals("server", root.getAt("global.name").asRequired(String.class)); + assertEquals(Optional.of("server"), root.getAt("global.name").asOptional(String.class)); assertEquals(List.of("1", "2"), root.get("list").listOf(String.class)); + assertTrue(root.get("list").isList()); + assertEquals(2, root.get("list").size()); assertEquals(Map.of("a", 1), root.get("weights").mapValues(Integer.class)); assertTrue(root.keys().contains("global")); assertTrue(root.children().containsKey("global")); @@ -28,6 +35,8 @@ void traversalAndTypedChildViewsWork() { void missingAndNonMapNodesAreSafe() { ConfigNode missing = ConfigNode.ofRaw(null, "x"); assertTrue(missing.isNull()); + assertTrue(missing.asOptional(String.class).isEmpty()); + assertEquals(0, missing.size()); assertNull(missing.raw()); assertEquals("fallback", missing.as(String.class, "fallback")); assertThrows(IllegalStateException.class, () -> missing.asRequired(String.class)); diff --git a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigServiceTest.java b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigServiceTest.java index 6cc331d..5b73f55 100644 --- a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigServiceTest.java +++ b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigServiceTest.java @@ -29,6 +29,11 @@ void openCachesFilesAndRejectsPathEscape() { assertSame(first, second); assertTrue(service.exists("config.yml")); + assertTrue(service.isCached("config.yml")); + assertEquals(1, service.cachedFileCount()); + assertEquals(java.util.Set.of(tempDir.resolve("config.yml").toAbsolutePath().normalize()), service.cachedPaths()); + assertThrows(UnsupportedOperationException.class, + () -> service.cachedPaths().add(tempDir.resolve("other.yml"))); assertThrows(IllegalArgumentException.class, () -> service.open("../evil.yml", false)); assertThrows(IllegalArgumentException.class, () -> service.open(" ", false)); assertThrows(IllegalArgumentException.class, @@ -116,10 +121,13 @@ void explicitReplacementRecoversAHandleWhoseLatestReloadFailed() throws Exceptio YamlFile cached = service.open("cached-broken.yml", false); Files.writeString(path, "key: [unterminated"); assertThrows(ConfigLoadException.class, cached::reload); + assertTrue(cached.hasLoadFailure()); + assertTrue(cached.loadFailure().isPresent()); service.replaceWithEmptyDocument("cached-broken.yml"); assertSame(cached, service.open("cached-broken.yml", false)); + assertFalse(cached.hasLoadFailure()); new ConfigView(cached, "").put("healthy", true); assertTrue(Files.readString(path).contains("healthy: true")); } @@ -128,8 +136,10 @@ void explicitReplacementRecoversAHandleWhoseLatestReloadFailed() throws Exceptio void deletingOptionalFileEvictsItsCachedHandle() throws Exception { ConfigService service = new ConfigService(tempDir, mock(Logger.class), null); YamlFile original = service.open("optional.yml", false); + assertTrue(service.isCached("optional.yml")); service.deleteOptional("optional.yml"); assertFalse(service.exists("optional.yml")); + assertFalse(service.isCached("optional.yml")); YamlFile recreated = service.open("optional.yml", false); assertNotSame(original, recreated); diff --git a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigViewTest.java b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigViewTest.java index d2af375..13205f3 100644 --- a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigViewTest.java +++ b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigViewTest.java @@ -7,6 +7,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.logging.Logger; import static org.junit.jupiter.api.Assertions.*; @@ -24,12 +25,18 @@ void typedCrudScopesAndDefaultsWork() throws Exception { view.put("numbers", List.of("1", 2)); view.put("weights", Map.of("a", "1")); + assertTrue(view.contains("global.name")); + assertFalse(view.contains("global.missing")); assertEquals("server", view.get("global.name", String.class)); + assertEquals(Optional.of("server"), view.getOptional("global.name", String.class)); + assertTrue(view.getOptional("missing", String.class).isEmpty()); assertEquals("fallback", view.get("missing", String.class, "fallback")); assertEquals(List.of(1, 2), view.getList("numbers", Integer.class)); assertEquals(Map.of("a", 1), view.getMapValues("weights", Integer.class)); assertEquals("server", view.nodeAt("global.name").asRequired(String.class)); assertEquals("server", view.getAt("global.name", String.class)); + assertEquals(Optional.of("server"), view.getAtOptional("global.name", String.class)); + assertTrue(view.getAtOptional("global.missing", String.class).isEmpty()); assertEquals("fallback", view.getAt("global.missing", String.class, "fallback")); assertTrue(view.putIfAbsent("global.mode", "on")); @@ -55,9 +62,14 @@ void computeListBatchAndRawMutationsPersistAtomically() throws Exception { assertEquals(List.of("b"), view.getList("items", String.class)); assertEquals(0, view.removeFromList("missing", ignored -> true)); + view.putAll(Map.of("bulk.one", 1, "bulk.two", 2)); + assertEquals(1, view.get("bulk.one", Integer.class)); + assertEquals(2, view.get("bulk.two", Integer.class)); + view.batch(batch -> { try { batch.put("batch.value", 1) + .putAll(Map.of("batch.extra", true)) .putIfAbsent("batch.value", 2) .compute("batch.value", Integer.class, value -> value + 1, () -> 0) .appendToList("batch.items", "x") @@ -68,6 +80,7 @@ void computeListBatchAndRawMutationsPersistAtomically() throws Exception { } }); assertEquals(2, view.get("batch.value", Integer.class)); + assertEquals(true, view.get("batch.extra", Boolean.class)); assertEquals(List.of("y"), view.getList("batch.items", String.class)); view.batch(batch -> { diff --git a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/YamlFileTest.java b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/YamlFileTest.java index 1bea790..4d01dc5 100644 --- a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/YamlFileTest.java +++ b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/config/YamlFileTest.java @@ -22,6 +22,9 @@ void readWriteMutateAndRootReplacementWork() throws Exception { Files.createFile(path); YamlFile yaml = new YamlFile(path, mock(Logger.class)); + assertEquals(path.toAbsolutePath().normalize(), yaml.path()); + assertFalse(yaml.hasLoadFailure()); + assertTrue(yaml.loadFailure().isEmpty()); yaml.setRawAndSave("global.name", "server"); assertEquals("server", yaml.getRaw("global.name")); assertNull(yaml.getRaw("global.missing")); @@ -46,6 +49,22 @@ void malformedYamlFailsWithoutInventingReplacementState() throws Exception { assertTrue(Files.readString(path).contains("[broken")); } + @Test + void failedReloadExposesDiagnosticsAndRecoveryClearsThem() throws Exception { + Path path = tempDir.resolve("reload.yml"); + Files.createFile(path); + YamlFile yaml = new YamlFile(path, mock(Logger.class)); + Files.writeString(path, "global: [broken"); + + ConfigLoadException failure = assertThrows(ConfigLoadException.class, yaml::reload); + assertTrue(yaml.hasLoadFailure()); + assertSame(failure, yaml.loadFailure().orElseThrow()); + + yaml.replaceWithEmptyDocument(); + assertFalse(yaml.hasLoadFailure()); + assertTrue(yaml.loadFailure().isEmpty()); + } + @Test void failedPersistenceDoesNotPublishCandidateInMemory() throws Exception { Path path = tempDir.resolve("stable.yml"); diff --git a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/localization/LanguageTest.java b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/localization/LanguageTest.java index d9b6cda..370f21c 100644 --- a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/localization/LanguageTest.java +++ b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/localization/LanguageTest.java @@ -5,14 +5,21 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; class LanguageTest { @Test void languageFileAndLocalizationContractsAreStable() { + assertEquals("NL", Language.NL.code()); assertEquals("messages_NL.yml", Language.NL.getFileName()); assertEquals("messages_EN.yml", Language.EN.getFileName()); assertTrue(Language.NL.isLocalizable()); - assertEquals(List.of(Language.NL, Language.EN), Language.localizableValues()); + assertEquals(Language.NL, Language.fromCode(" nl ").orElseThrow()); + assertTrue(Language.fromCode("unknown").isEmpty()); + assertTrue(Language.fromCode(null).isEmpty()); + List localizable = Language.localizableValues(); + assertEquals(List.of(Language.NL, Language.EN), localizable); + assertSame(localizable, Language.localizableValues()); } } diff --git a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/localization/MessageMapTest.java b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/localization/MessageMapTest.java new file mode 100644 index 0000000..cddf816 --- /dev/null +++ b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/io/localization/MessageMapTest.java @@ -0,0 +1,29 @@ +package nl.hauntedmc.featureframework.toolkit.io.localization; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MessageMapTest { + @Test + void supportsFluentAndBulkDefaultConstruction() { + Map additional = new LinkedHashMap<>(); + additional.put("second", "Two"); + additional.put("third", "Three"); + + MessageMap messages = new MessageMap() + .put("first", "One") + .putAll(additional); + + assertEquals(3, messages.size()); + assertFalse(messages.isEmpty()); + assertTrue(messages.contains("second")); + assertEquals("One", messages.getMessages().get("first")); + assertTrue(new MessageMap().isEmpty()); + } +} diff --git a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/pagination/PaginatorTest.java b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/pagination/PaginatorTest.java index 0212ac4..55c7286 100644 --- a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/pagination/PaginatorTest.java +++ b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/pagination/PaginatorTest.java @@ -5,7 +5,9 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class PaginatorTest { @Test @@ -14,11 +16,29 @@ void emptyInputAndPageClampingAreStable() { assertEquals(List.of(), empty.items()); assertEquals(1, empty.page()); assertEquals(1, empty.totalPages()); + assertTrue(empty.isFirst()); + assertTrue(empty.isLast()); + assertFalse(empty.hasPrevious()); + assertFalse(empty.hasNext()); + assertEquals(1, empty.previousPage()); + assertEquals(1, empty.nextPage()); + + Paginator.Page middle = Paginator.paginate(List.of(1, 2, 3, 4, 5), 2, 2); + assertTrue(middle.hasPrevious()); + assertTrue(middle.hasNext()); + assertFalse(middle.isFirst()); + assertFalse(middle.isLast()); + assertEquals(1, middle.previousPage()); + assertEquals(3, middle.nextPage()); + Paginator.Page last = Paginator.paginate(List.of(1, 2, 3, 4, 5), 99, 2); assertEquals(List.of(5), last.items()); assertEquals(3, last.page()); assertEquals(3, last.totalPages()); assertEquals(5, last.totalItems()); + assertTrue(last.isLast()); + assertFalse(last.hasNext()); + assertEquals(3, last.nextPage()); assertThrows(IllegalArgumentException.class, () -> Paginator.paginate(List.of(1), 1, 0)); } } diff --git a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/text/placeholder/MessagePlaceholdersTest.java b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/text/placeholder/MessagePlaceholdersTest.java index f25cf5d..2e8f476 100644 --- a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/text/placeholder/MessagePlaceholdersTest.java +++ b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/text/placeholder/MessagePlaceholdersTest.java @@ -20,15 +20,21 @@ void builderSupportsTypedValuesAndLongestKeysAreAppliedFirst() { .build(); assertEquals("Alice", placeholders.get("player")); - assertEquals("AliceTheGreat/Alice/3", - MessagePlaceholders.applyPlaceholders("{player_name}/{player}/{count}", placeholders)); + assertTrue(placeholders.contains("count")); + assertEquals(5, placeholders.size()); + assertFalse(placeholders.isEmpty()); + assertEquals(placeholders.get("player"), placeholders.asMap().get("player")); + assertEquals("AliceTheGreat/Alice/3", placeholders.apply("{player_name}/{player}/{count}")); assertTrue(placeholders.get("component").contains("Hello")); assertEquals("", placeholders.get("nullable")); assertTrue(placeholders.toString().contains("player")); + assertThrows(UnsupportedOperationException.class, () -> placeholders.asMap().put("x", "y")); } @Test void emptyAndMapFactoriesAreSafe() { + assertSame(MessagePlaceholders.empty(), MessagePlaceholders.empty()); + assertTrue(MessagePlaceholders.empty().isEmpty()); assertEquals("unchanged", MessagePlaceholders.applyPlaceholders("unchanged", MessagePlaceholders.empty())); assertNull(MessagePlaceholders.applyPlaceholders(null, MessagePlaceholders.empty())); assertEquals("x", MessagePlaceholders.of(Map.of("k", "x")).get("k")); diff --git a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/token/TokenServiceTest.java b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/token/TokenServiceTest.java index 60ee032..a489cdc 100644 --- a/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/token/TokenServiceTest.java +++ b/featureframework-toolkit/src/test/java/nl/hauntedmc/featureframework/toolkit/token/TokenServiceTest.java @@ -3,9 +3,13 @@ import org.junit.jupiter.api.Test; import java.time.Duration; +import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class TokenServiceTest { @@ -17,9 +21,15 @@ void finiteTokenExpiresAfterItsConfiguredUses() { TokenOptions.of(2, Duration.ofMinutes(1), true) ); + TokenResult first = tokens.consume(token); + assertEquals(TokenResult.State.OK, first.state()); + assertTrue(first.isOk()); + assertEquals(Optional.of("payload"), first.payloadOptional()); assertEquals(TokenResult.State.OK, tokens.consume(token).state()); - assertEquals(TokenResult.State.OK, tokens.consume(token).state()); - assertEquals(TokenResult.State.INVALID, tokens.consume(token).state()); + TokenResult invalid = tokens.consume(token); + assertEquals(TokenResult.State.INVALID, invalid.state()); + assertFalse(invalid.isOk()); + assertTrue(invalid.payloadOptional().isEmpty()); assertEquals(0, tokens.size()); } @@ -35,6 +45,22 @@ void loadingAndEmptyPayloadStatesAreExplicit() { assertEquals(1, tokens.size()); tokens.revoke(loadingToken); - assertEquals(0, tokens.size()); + assertTrue(tokens.isEmpty()); + } + + @Test + void acceptsGenericCompletionStagesAndSupportsLifecycleConveniences() { + TokenService tokens = new TokenService<>("test-namespace"); + CompletionStage stage = CompletableFuture.completedFuture("value"); + + String first = tokens.create(() -> stage); + String second = tokens.create(() -> CompletableFuture.completedFuture("other")); + + assertEquals("test-namespace", tokens.namespace()); + assertEquals("value", tokens.consume(first).payload()); + assertEquals(2, tokens.size()); + tokens.clear(); + assertTrue(tokens.isEmpty()); + assertEquals(TokenResult.State.INVALID, tokens.consume(second).state()); } } diff --git a/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java b/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java index 8ad7bb3..41b98ed 100644 --- a/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java +++ b/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java @@ -240,6 +240,12 @@ public Builder afterHostResourcesReload(Runnable value) { public Builder contribute(FeatureResourceContributor value) { contributors.add(Objects.requireNonNull(value, "contributor")); return this; } + public Builder contributors( + Iterable> values + ) { + Objects.requireNonNull(values, "contributors").forEach(this::contribute); + return this; + } public Builder bootstrapCapability(Class type, T value) { bootstrapCapabilities.add(new BootstrapCapability<>(type, type.cast(value))); return this; }