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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
/** Read-only catalog of all features managed by one host plugin. */
public interface FeatureCatalog {
Optional<FeatureSnapshot> find(FeatureId id);

/** Finds a feature from external text, returning empty for null or malformed identifiers. */
default Optional<FeatureSnapshot> findByName(String id) {
return FeatureId.tryParse(id).flatMap(this::find);
}

List<FeatureSnapshot> snapshot();
AutoCloseable subscribe(FeatureCatalogListener listener);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@

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<FeatureId> {
public static final int MAX_LENGTH = 64;

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);
}
}
Expand All @@ -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.
*
* <p>This applies the same trimming and case normalization as {@link #of(String)} without
* throwing for null or malformed input.</p>
*/
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<FeatureId> 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);
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,11 @@ public FeatureSnapshot(
);
}

public boolean active() {
return state == FeatureState.ACTIVE;
}

public boolean failed() {
return state == FeatureState.FAILED;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,36 @@ public record ServerId(String value) implements Comparable<ServerId> {

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);
}
}

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<ServerId> tryParse(String value) {
return isValid(value) ? Optional.of(new ServerId(value)) : Optional.empty();
}

public static Optional<ServerId> optional(String value) {
return value == null || value.isBlank() ? Optional.empty() : Optional.of(new ServerId(value));
}
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
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. */
public interface CapabilityRegistry {
/** Returns a stable reference for the requested public contract. */
<T> CapabilityRef<T> reference(Class<T> type);

/** Resolves the currently active implementation, if available. */
default <T> Optional<T> findCapability(Class<T> type) {
return reference(type).get();
}

/** Resolves the currently active implementation or fails with a descriptive exception. */
default <T> T requireCapability(Class<T> 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<Class<?>> availableTypes();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -73,14 +72,15 @@ private List<FeatureSuggestion> suggestions(String prefix, boolean loadedOnly) {

private Optional<FeatureSnapshot> 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<FeatureSnapshot> byId() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<FeatureSnapshot> find(FeatureId id) {
return id.equals(metadata.id()) ? Optional.of(snapshot) : Optional.empty();
}

@Override
public List<FeatureSnapshot> 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(
Expand All @@ -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
Expand All @@ -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()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,12 @@ public DependencyCheckResult(Set<String> missingPluginDependencies, Set<String>
public boolean ok() {
return missingPluginDependencies.isEmpty() && missingFeatureDependencies.isEmpty();
}

public boolean hasMissingDependencies() {
return !ok();
}

public int missingDependencyCount() {
return missingPluginDependencies.size() + missingFeatureDependencies.size();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ public List<FeatureDefinition<F, C>> 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<F extends Feature, C> {
private final List<FeatureDefinition<F, C>> definitions = new ArrayList<>();
Expand All @@ -63,6 +67,12 @@ public Builder<F, C> feature(FeatureDefinition<? extends F, C> definition) {
return this;
}

/** Adds an ordered batch of definitions without forcing callers to loop manually. */
public Builder<F, C> features(Iterable<? extends FeatureDefinition<? extends F, C>> values) {
Objects.requireNonNull(values, "definitions").forEach(this::feature);
return this;
}

public Builder<F, C> include(FeatureCollection<? extends F, C> collection) {
Objects.requireNonNull(collection, "collection");
collection.definitions().forEach(this::feature);
Expand Down
Loading