diff --git a/CHANGELOG.md b/CHANGELOG.md index e7daceaa1c66c..6a6da8f61f348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased 2.x] ### Added - Improve performace of NumericTermAggregation by avoiding unnecessary sorting([#17252](https://github.com/opensearch-project/OpenSearch/pull/17252)) +- [Rule Based Auto-tagging] Add rule schema for auto tagging ([#17238](https://github.com/opensearch-project/OpenSearch/pull/17238)) - Add execution_hint to cardinality aggregator request (#[17419](https://github.com/opensearch-project/OpenSearch/pull/17419)) - [Rule Based Auto-tagging] Add in-memory attribute value store ([#17342](https://github.com/opensearch-project/OpenSearch/pull/17342)) diff --git a/server/src/main/java/org/opensearch/autotagging/Attribute.java b/server/src/main/java/org/opensearch/autotagging/Attribute.java new file mode 100644 index 0000000000000..61dfc7e704c20 --- /dev/null +++ b/server/src/main/java/org/opensearch/autotagging/Attribute.java @@ -0,0 +1,59 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.autotagging; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.io.stream.Writeable; + +import java.io.IOException; + +/** + * Represents an attribute within the auto-tagging feature. Attributes define characteristics that can + * be used for tagging and classification. Implementations must ensure that attributes + * are uniquely identifiable by their name. Attributes should be singletons and managed centrally to + * avoid duplicates. + * + * @opensearch.experimental + */ +public interface Attribute extends Writeable { + String getName(); + + /** + * Ensure that `validateAttribute` is called in the constructor of attribute implementations + * to prevent potential serialization issues. + */ + default void validateAttribute() { + String name = getName(); + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("Attribute name cannot be null or empty"); + } + } + + @Override + default void writeTo(StreamOutput out) throws IOException { + out.writeString(getName()); + } + + /** + * Retrieves an attribute from the given feature type based on its name. + * Implementations of `FeatureType.getAttributeFromName` must be thread-safe as this method + * may be called concurrently. + * @param in - the {@link StreamInput} from which the attribute name is read + * @param featureType - the FeatureType used to look up the attribute + */ + static Attribute from(StreamInput in, FeatureType featureType) throws IOException { + String attributeName = in.readString(); + Attribute attribute = featureType.getAttributeFromName(attributeName); + if (attribute == null) { + throw new IllegalStateException(attributeName + " is not a valid attribute under feature type " + featureType.getName()); + } + return attribute; + } +} diff --git a/server/src/main/java/org/opensearch/autotagging/AutoTaggingRegistry.java b/server/src/main/java/org/opensearch/autotagging/AutoTaggingRegistry.java new file mode 100644 index 0000000000000..394b89922dd2b --- /dev/null +++ b/server/src/main/java/org/opensearch/autotagging/AutoTaggingRegistry.java @@ -0,0 +1,68 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.autotagging; + +import org.opensearch.ResourceNotFoundException; + +import java.util.HashMap; +import java.util.Map; + +/** + * Registry for managing auto-tagging attributes and feature types. + * This class provides functionality to register and retrieve {@link Attribute} and {@link FeatureType} instances + * used for auto-tagging. + * + * @opensearch.experimental + */ +public class AutoTaggingRegistry { + /** + * featureTypesRegistryMap should be concurrently readable but not concurrently writable. + * The registration of FeatureType should only be done during boot-up. + */ + public static final Map featureTypesRegistryMap = new HashMap<>(); + public static final int MAX_FEATURE_TYPE_NAME_LENGTH = 30; + + public static void registerFeatureType(FeatureType featureType) { + validateFeatureType(featureType); + String name = featureType.getName(); + if (featureTypesRegistryMap.containsKey(name) && featureTypesRegistryMap.get(name) != featureType) { + throw new IllegalStateException("Feature type " + name + " is already registered. Duplicate feature type is not allowed."); + } + featureTypesRegistryMap.put(name, featureType); + } + + private static void validateFeatureType(FeatureType featureType) { + if (featureType == null) { + throw new IllegalStateException("Feature type can't be null. Unable to register."); + } + String name = featureType.getName(); + if (name == null || name.isEmpty() || name.length() > MAX_FEATURE_TYPE_NAME_LENGTH) { + throw new IllegalStateException( + "Feature type name " + name + " should not be null, empty or have more than " + MAX_FEATURE_TYPE_NAME_LENGTH + "characters" + ); + } + } + + /** + * Retrieves the registered {@link FeatureType} instance based on class name and feature type name. + * This method assumes that FeatureTypes are singletons, meaning that each unique + * (className, featureTypeName) pair corresponds to a single, globally shared instance. + * + * @param featureTypeName The name of the feature type. + */ + public static FeatureType getFeatureType(String featureTypeName) { + FeatureType featureType = featureTypesRegistryMap.get(featureTypeName); + if (featureType == null) { + throw new ResourceNotFoundException( + "Couldn't find a feature type with name: " + featureTypeName + ". Make sure you have registered it." + ); + } + return featureType; + } +} diff --git a/server/src/main/java/org/opensearch/autotagging/FeatureType.java b/server/src/main/java/org/opensearch/autotagging/FeatureType.java new file mode 100644 index 0000000000000..b446f62f6d764 --- /dev/null +++ b/server/src/main/java/org/opensearch/autotagging/FeatureType.java @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.autotagging; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.io.stream.Writeable; + +import java.io.IOException; +import java.util.Map; + +/** + * Represents a feature type within the auto-tagging feature. Feature types define different categories of + * characteristics that can be used for tagging and classification. Implementations of this interface are + * responsible for registering feature types in {@link AutoTaggingRegistry}. Implementations must ensure that + * feature types are uniquely identifiable by their class and name. + * + * Implementers should follow these guidelines: + * Feature types should be singletons and managed centrally to avoid duplicates. + * {@link #registerFeatureType()} must be called during initialization to ensure the feature type is available. + * + * @opensearch.experimental + */ +public interface FeatureType extends Writeable { + int DEFAULT_MAX_ATTRIBUTE_VALUES = 10; + int DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH = 100; + + String getName(); + + /** + * Returns the registry of allowed attributes for this feature type. + * Implementations must ensure that access to this registry is thread-safe. + */ + Map getAllowedAttributesRegistry(); + + default int getMaxNumberOfValuesPerAttribute() { + return DEFAULT_MAX_ATTRIBUTE_VALUES; + } + + default int getMaxCharLengthPerAttributeValue() { + return DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH; + } + + void registerFeatureType(); + + default boolean isValidAttribute(Attribute attribute) { + return getAllowedAttributesRegistry().containsValue(attribute); + } + + /** + * Retrieves an attribute by its name from the allowed attributes' registry. + * Implementations must ensure that this method is thread-safe. + * @param name The name of the attribute. + */ + default Attribute getAttributeFromName(String name) { + return getAllowedAttributesRegistry().get(name); + } + + @Override + default void writeTo(StreamOutput out) throws IOException { + out.writeString(getName()); + } + + static FeatureType from(StreamInput in) throws IOException { + return AutoTaggingRegistry.getFeatureType(in.readString()); + } +} diff --git a/server/src/main/java/org/opensearch/autotagging/Rule.java b/server/src/main/java/org/opensearch/autotagging/Rule.java new file mode 100644 index 0000000000000..0f4adb5e462f5 --- /dev/null +++ b/server/src/main/java/org/opensearch/autotagging/Rule.java @@ -0,0 +1,255 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.autotagging; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParseException; +import org.opensearch.core.xcontent.XContentParser; + +import java.io.IOException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Represents a rule schema used for automatic query tagging in the system. + * This class encapsulates the criteria (defined through attributes) for automatically applying relevant + * tags to queries based on matching attribute patterns. This class provides an in-memory representation + * of a rule. The indexed view may differ in representation. + * { + * "_id": "fwehf8302582mglfio349==", + * "description": "Assign Query Group for Index Logs123" + * "index_pattern": ["logs123"], + * "query_group": "dev_query_group_id", + * "updated_at": "01-10-2025T21:23:21.456Z" + * } + * @opensearch.experimental + */ +public class Rule implements Writeable, ToXContentObject { + private final String description; + private final FeatureType featureType; + private final Map> attributeMap; + private final String featureValue; + private final String updatedAt; + private final RuleValidator ruleValidator; + public static final String _ID_STRING = "_id"; + public static final String DESCRIPTION_STRING = "description"; + public static final String UPDATED_AT_STRING = "updated_at"; + + public Rule( + String description, + Map> attributeMap, + FeatureType featureType, + String featureValue, + String updatedAt + ) { + this.description = description; + this.featureType = featureType; + this.attributeMap = attributeMap; + this.featureValue = featureValue; + this.updatedAt = updatedAt; + this.ruleValidator = new RuleValidator(description, attributeMap, featureValue, updatedAt, featureType); + this.ruleValidator.validate(); + } + + public Rule(StreamInput in) throws IOException { + description = in.readString(); + featureType = FeatureType.from(in); + attributeMap = in.readMap(i -> Attribute.from(i, featureType), i -> new HashSet<>(i.readStringList())); + featureValue = in.readString(); + updatedAt = in.readString(); + this.ruleValidator = new RuleValidator(description, attributeMap, featureValue, updatedAt, featureType); + this.ruleValidator.validate(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(description); + featureType.writeTo(out); + out.writeMap(attributeMap, (output, attribute) -> attribute.writeTo(output), StreamOutput::writeStringCollection); + out.writeString(featureValue); + out.writeString(updatedAt); + } + + public static Rule fromXContent(final XContentParser parser, FeatureType featureType) throws IOException { + return Builder.fromXContent(parser, featureType).build(); + } + + public String getDescription() { + return description; + } + + public String getFeatureValue() { + return featureValue; + } + + public String getUpdatedAt() { + return updatedAt; + } + + public FeatureType getFeatureType() { + return featureType; + } + + public Map> getAttributeMap() { + return attributeMap; + } + + @Override + public XContentBuilder toXContent(final XContentBuilder builder, final Params params) throws IOException { + builder.startObject(); + String id = params.param(_ID_STRING); + if (id != null) { + builder.field(_ID_STRING, id); + } + builder.field(DESCRIPTION_STRING, description); + for (Map.Entry> entry : attributeMap.entrySet()) { + builder.array(entry.getKey().getName(), entry.getValue().toArray(new String[0])); + } + builder.field(featureType.getName(), featureValue); + builder.field(UPDATED_AT_STRING, updatedAt); + builder.endObject(); + return builder; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Rule that = (Rule) o; + return Objects.equals(description, that.description) + && Objects.equals(featureValue, that.featureValue) + && Objects.equals(featureType, that.featureType) + && Objects.equals(attributeMap, that.attributeMap) + && Objects.equals(ruleValidator, that.ruleValidator) + && Objects.equals(updatedAt, that.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(description, featureValue, featureType, attributeMap, updatedAt); + } + + /** + * builder method for the {@link Rule} + * @return Builder object + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder class for {@link Rule} + * @opensearch.experimental + */ + public static class Builder { + private String description; + private Map> attributeMap; + private FeatureType featureType; + private String featureValue; + private String updatedAt; + + private Builder() {} + + public static Builder fromXContent(XContentParser parser, FeatureType featureType) throws IOException { + if (parser.currentToken() == null) { + parser.nextToken(); + } + Builder builder = builder(); + XContentParser.Token token = parser.currentToken(); + + if (token != XContentParser.Token.START_OBJECT) { + throw new XContentParseException("Expected START_OBJECT token but found [" + parser.currentName() + "]"); + } + Map> attributeMap1 = new HashMap<>(); + String fieldName = ""; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + fieldName = parser.currentName(); + } else if (token.isValue()) { + if (fieldName.equals(DESCRIPTION_STRING)) { + builder.description(parser.text()); + } else if (fieldName.equals(UPDATED_AT_STRING)) { + builder.updatedAt(parser.text()); + } else if (fieldName.equals(featureType.getName())) { + builder.featureType(featureType); + builder.featureValue(parser.text()); + } + } else if (token == XContentParser.Token.START_ARRAY) { + fromXContentParseArray(parser, fieldName, featureType, attributeMap1); + } + } + return builder.attributeMap(attributeMap1); + } + + public static void fromXContentParseArray( + XContentParser parser, + String fieldName, + FeatureType featureType, + Map> attributeMap + ) throws IOException { + Attribute attribute = featureType.getAttributeFromName(fieldName); + if (attribute == null) { + throw new XContentParseException(fieldName + " is not a valid attribute within the " + featureType.getName() + " feature."); + } + Set attributeValueSet = new HashSet<>(); + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + if (parser.currentToken() == XContentParser.Token.VALUE_STRING) { + attributeValueSet.add(parser.text()); + } else { + throw new XContentParseException("Unexpected token in array: " + parser.currentToken()); + } + } + attributeMap.put(attribute, attributeValueSet); + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder featureValue(String featureValue) { + this.featureValue = featureValue; + return this; + } + + public Builder attributeMap(Map> attributeMap) { + this.attributeMap = attributeMap; + return this; + } + + public Builder featureType(FeatureType featureType) { + this.featureType = featureType; + return this; + } + + public Builder updatedAt(String updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + public Rule build() { + return new Rule(description, attributeMap, featureType, featureValue, updatedAt); + } + + public String getFeatureValue() { + return featureValue; + } + + public Map> getAttributeMap() { + return attributeMap; + } + } +} diff --git a/server/src/main/java/org/opensearch/autotagging/RuleValidator.java b/server/src/main/java/org/opensearch/autotagging/RuleValidator.java new file mode 100644 index 0000000000000..625d7ba94d282 --- /dev/null +++ b/server/src/main/java/org/opensearch/autotagging/RuleValidator.java @@ -0,0 +1,170 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.autotagging; + +import org.opensearch.common.ValidationException; +import org.joda.time.Instant; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import static org.opensearch.cluster.metadata.QueryGroup.isValid; + +/** + * This is the validator for rule. It ensures that the rule has a valid description, feature value, + * update time, attribute map, and the rule adheres to the feature type's constraints. + * + * @opensearch.experimental + */ +public class RuleValidator { + private final String description; + private final Map> attributeMap; + private final String featureValue; + private final String updatedAt; + private final FeatureType featureType; + public static final int MAX_DESCRIPTION_LENGTH = 256; + + public RuleValidator( + String description, + Map> attributeMap, + String featureValue, + String updatedAt, + FeatureType featureType + ) { + this.description = description; + this.attributeMap = attributeMap; + this.featureValue = featureValue; + this.updatedAt = updatedAt; + this.featureType = featureType; + } + + public void validate() { + List errorMessages = new ArrayList<>(); + errorMessages.addAll(validateStringFields()); + errorMessages.addAll(validateFeatureType()); + errorMessages.addAll(validateUpdatedAtEpoch()); + errorMessages.addAll(validateAttributeMap()); + if (!errorMessages.isEmpty()) { + ValidationException validationException = new ValidationException(); + validationException.addValidationErrors(errorMessages); + throw new IllegalArgumentException(validationException); + } + } + + private List validateStringFields() { + List errors = new ArrayList<>(); + if (isNullOrEmpty(description)) { + errors.add("Rule description can't be null or empty"); + } else if (description.length() > MAX_DESCRIPTION_LENGTH) { + errors.add("Rule description cannot exceed " + MAX_DESCRIPTION_LENGTH + " characters."); + } + if (isNullOrEmpty(featureValue)) { + errors.add("Rule featureValue can't be null or empty"); + } + if (isNullOrEmpty(updatedAt)) { + errors.add("Rule update time can't be null or empty"); + } + return errors; + } + + private boolean isNullOrEmpty(String str) { + return str == null || str.isEmpty(); + } + + private List validateFeatureType() { + if (featureType == null) { + return List.of("Couldn't identify which feature the rule belongs to. Rule feature can't be null."); + } + return new ArrayList<>(); + } + + private List validateUpdatedAtEpoch() { + if (updatedAt != null && !isValid(Instant.parse(updatedAt).getMillis())) { + return List.of("Rule update time is not a valid epoch"); + } + return new ArrayList<>(); + } + + private List validateAttributeMap() { + List errors = new ArrayList<>(); + if (attributeMap == null || attributeMap.isEmpty()) { + errors.add("Rule should have at least 1 attribute requirement"); + } + + if (attributeMap != null && featureType != null) { + for (Map.Entry> entry : attributeMap.entrySet()) { + Attribute attribute = entry.getKey(); + Set attributeValues = entry.getValue(); + errors.addAll(validateAttributeExistence(attribute)); + errors.addAll(validateMaxAttributeValues(attribute, attributeValues)); + errors.addAll(validateAttributeValuesLength(attributeValues)); + } + } + return errors; + } + + private List validateAttributeExistence(Attribute attribute) { + if (featureType.getAttributeFromName(attribute.getName()) == null) { + return List.of(attribute.getName() + " is not a valid attribute within the " + featureType.getName() + " feature."); + } + return new ArrayList<>(); + } + + private List validateMaxAttributeValues(Attribute attribute, Set attributeValues) { + List errors = new ArrayList<>(); + String attributeName = attribute.getName(); + if (attributeValues.isEmpty()) { + errors.add("Attribute values for " + attributeName + " cannot be empty."); + } + int maxSize = featureType.getMaxNumberOfValuesPerAttribute(); + int actualSize = attributeValues.size(); + if (actualSize > maxSize) { + errors.add( + "Each attribute can only have a maximum of " + + maxSize + + " values. The input attribute " + + attributeName + + " has length " + + attributeValues.size() + + ", which exceeds this limit." + ); + } + return errors; + } + + private List validateAttributeValuesLength(Set attributeValues) { + int maxValueLength = featureType.getMaxCharLengthPerAttributeValue(); + for (String attributeValue : attributeValues) { + if (attributeValue.isEmpty() || attributeValue.length() > maxValueLength) { + return List.of("Attribute value [" + attributeValue + "] is invalid (empty or exceeds " + maxValueLength + " characters)"); + } + } + return new ArrayList<>(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RuleValidator that = (RuleValidator) o; + return Objects.equals(description, that.description) + && Objects.equals(attributeMap, that.attributeMap) + && Objects.equals(featureValue, that.featureValue) + && Objects.equals(updatedAt, that.updatedAt) + && Objects.equals(featureType, that.featureType); + } + + @Override + public int hashCode() { + return Objects.hash(description, attributeMap, featureValue, updatedAt, featureType); + } +} diff --git a/server/src/main/java/org/opensearch/autotagging/package-info.java b/server/src/main/java/org/opensearch/autotagging/package-info.java new file mode 100644 index 0000000000000..1c0794c18241b --- /dev/null +++ b/server/src/main/java/org/opensearch/autotagging/package-info.java @@ -0,0 +1,13 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/** + * This package contains auto tagging constructs + */ + +package org.opensearch.autotagging; diff --git a/server/src/test/java/org/opensearch/autotagging/AutoTaggingRegistryTests.java b/server/src/test/java/org/opensearch/autotagging/AutoTaggingRegistryTests.java new file mode 100644 index 0000000000000..8bd240dad99e6 --- /dev/null +++ b/server/src/test/java/org/opensearch/autotagging/AutoTaggingRegistryTests.java @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.autotagging; + +import org.opensearch.ResourceNotFoundException; +import org.opensearch.test.OpenSearchTestCase; +import org.junit.BeforeClass; + +import static org.opensearch.autotagging.AutoTaggingRegistry.MAX_FEATURE_TYPE_NAME_LENGTH; +import static org.opensearch.autotagging.RuleTests.INVALID_FEATURE; +import static org.opensearch.autotagging.RuleTests.TEST_FEATURE_TYPE; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class AutoTaggingRegistryTests extends OpenSearchTestCase { + + @BeforeClass + public static void setUpOnce() { + FeatureType featureType = mock(FeatureType.class); + when(featureType.getName()).thenReturn(TEST_FEATURE_TYPE); + AutoTaggingRegistry.registerFeatureType(featureType); + } + + public void testGetFeatureType_Success() { + FeatureType retrievedFeatureType = AutoTaggingRegistry.getFeatureType(TEST_FEATURE_TYPE); + assertEquals(TEST_FEATURE_TYPE, retrievedFeatureType.getName()); + } + + public void testRuntimeException() { + assertThrows(ResourceNotFoundException.class, () -> AutoTaggingRegistry.getFeatureType(INVALID_FEATURE)); + } + + public void testIllegalStateExceptionException() { + assertThrows(IllegalStateException.class, () -> AutoTaggingRegistry.registerFeatureType(null)); + FeatureType featureType = mock(FeatureType.class); + when(featureType.getName()).thenReturn(TEST_FEATURE_TYPE); + assertThrows(IllegalStateException.class, () -> AutoTaggingRegistry.registerFeatureType(featureType)); + when(featureType.getName()).thenReturn(randomAlphaOfLength(MAX_FEATURE_TYPE_NAME_LENGTH + 1)); + assertThrows(IllegalStateException.class, () -> AutoTaggingRegistry.registerFeatureType(featureType)); + } +} diff --git a/server/src/test/java/org/opensearch/autotagging/FeatureTypeTests.java b/server/src/test/java/org/opensearch/autotagging/FeatureTypeTests.java new file mode 100644 index 0000000000000..e8cf46818c515 --- /dev/null +++ b/server/src/test/java/org/opensearch/autotagging/FeatureTypeTests.java @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.autotagging; + +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.test.OpenSearchTestCase; + +import java.io.IOException; + +import static org.opensearch.autotagging.RuleTests.FEATURE_TYPE; +import static org.opensearch.autotagging.RuleTests.INVALID_ATTRIBUTE; +import static org.opensearch.autotagging.RuleTests.TEST_ATTR1_NAME; +import static org.opensearch.autotagging.RuleTests.TestAttribute.TEST_ATTRIBUTE_1; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class FeatureTypeTests extends OpenSearchTestCase { + public void testIsValidAttribute() { + assertTrue(FEATURE_TYPE.isValidAttribute(TEST_ATTRIBUTE_1)); + assertFalse(FEATURE_TYPE.isValidAttribute(mock(Attribute.class))); + } + + public void testGetAttributeFromName() { + assertEquals(TEST_ATTRIBUTE_1, FEATURE_TYPE.getAttributeFromName(TEST_ATTR1_NAME)); + assertNull(FEATURE_TYPE.getAttributeFromName(INVALID_ATTRIBUTE)); + } + + public void testWriteTo() throws IOException { + StreamOutput mockOutput = mock(StreamOutput.class); + FEATURE_TYPE.writeTo(mockOutput); + verify(mockOutput).writeString(anyString()); + } +} diff --git a/server/src/test/java/org/opensearch/autotagging/RuleTests.java b/server/src/test/java/org/opensearch/autotagging/RuleTests.java new file mode 100644 index 0000000000000..5f27942639126 --- /dev/null +++ b/server/src/test/java/org/opensearch/autotagging/RuleTests.java @@ -0,0 +1,167 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.autotagging; + +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.test.AbstractSerializingTestCase; + +import java.io.IOException; +import java.time.Instant; +import java.util.Map; +import java.util.Set; + +import static org.opensearch.autotagging.Rule._ID_STRING; +import static org.opensearch.autotagging.RuleTests.TestAttribute.TEST_ATTRIBUTE_1; +import static org.opensearch.autotagging.RuleTests.TestAttribute.TEST_ATTRIBUTE_2; + +public class RuleTests extends AbstractSerializingTestCase { + public static final String TEST_ATTR1_NAME = "test_attr1"; + public static final String TEST_ATTR2_NAME = "test_attr2"; + public static final String TEST_FEATURE_TYPE = "test_feature_type"; + public static final String DESCRIPTION = "description"; + public static final String _ID = "test_id"; + public static final String FEATURE_VALUE = "feature_value"; + public static final TestFeatureType FEATURE_TYPE = TestFeatureType.INSTANCE; + public static final Map> ATTRIBUTE_MAP = Map.of( + TEST_ATTRIBUTE_1, + Set.of("value1"), + TEST_ATTRIBUTE_2, + Set.of("value2") + ); + public static final String UPDATED_AT = "2025-02-24T07:42:10.123456Z"; + public static final String INVALID_CLASS = "invalid_class"; + public static final String INVALID_ATTRIBUTE = "invalid_attribute"; + public static final String INVALID_FEATURE = "invalid_feature"; + + @Override + protected Rule createTestInstance() { + String description = randomAlphaOfLength(10); + String featureValue = randomAlphaOfLength(5); + String updatedAt = Instant.now().toString(); + return new Rule(description, ATTRIBUTE_MAP, FEATURE_TYPE, featureValue, updatedAt); + } + + @Override + protected Writeable.Reader instanceReader() { + return Rule::new; + } + + @Override + protected Rule doParseInstance(XContentParser parser) throws IOException { + return Rule.fromXContent(parser, FEATURE_TYPE); + } + + public enum TestAttribute implements Attribute { + TEST_ATTRIBUTE_1(TEST_ATTR1_NAME), + TEST_ATTRIBUTE_2(TEST_ATTR2_NAME); + + private final String name; + + TestAttribute(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + } + + public static class TestFeatureType implements FeatureType { + public static final TestFeatureType INSTANCE = new TestFeatureType(); + private static final String NAME = TEST_FEATURE_TYPE; + private static final int MAX_ATTRIBUTE_VALUES = 10; + private static final int MAX_ATTRIBUTE_VALUE_LENGTH = 100; + private static final Map ALLOWED_ATTRIBUTES = Map.of( + TEST_ATTR1_NAME, + TEST_ATTRIBUTE_1, + TEST_ATTR2_NAME, + TEST_ATTRIBUTE_2 + ); + + public TestFeatureType() {} + + static { + INSTANCE.registerFeatureType(); + } + + @Override + public String getName() { + return NAME; + } + + @Override + public int getMaxNumberOfValuesPerAttribute() { + return MAX_ATTRIBUTE_VALUES; + } + + @Override + public int getMaxCharLengthPerAttributeValue() { + return MAX_ATTRIBUTE_VALUE_LENGTH; + } + + @Override + public Map getAllowedAttributesRegistry() { + return ALLOWED_ATTRIBUTES; + } + + @Override + public void registerFeatureType() { + AutoTaggingRegistry.registerFeatureType(INSTANCE); + } + } + + static Rule buildRule( + String featureValue, + FeatureType featureType, + Map> attributeListMap, + String updatedAt, + String description + ) { + return Rule.builder() + .featureValue(featureValue) + .featureType(featureType) + .description(description) + .attributeMap(attributeListMap) + .updatedAt(updatedAt) + .build(); + } + + public void testValidRule() { + Rule rule = buildRule(FEATURE_VALUE, FEATURE_TYPE, ATTRIBUTE_MAP, UPDATED_AT, DESCRIPTION); + assertNotNull(rule.getFeatureValue()); + assertEquals(FEATURE_VALUE, rule.getFeatureValue()); + assertNotNull(rule.getUpdatedAt()); + assertEquals(UPDATED_AT, rule.getUpdatedAt()); + Map> resultMap = rule.getAttributeMap(); + assertNotNull(resultMap); + assertFalse(resultMap.isEmpty()); + assertNotNull(rule.getFeatureType()); + } + + public void testToXContent() throws IOException { + String updatedAt = Instant.now().toString(); + Rule rule = buildRule(FEATURE_VALUE, FEATURE_TYPE, Map.of(TEST_ATTRIBUTE_1, Set.of("value1")), updatedAt, DESCRIPTION); + + XContentBuilder builder = JsonXContent.contentBuilder(); + rule.toXContent(builder, new ToXContent.MapParams(Map.of(_ID_STRING, _ID))); + assertEquals( + "{\"_id\":\"" + + _ID + + "\",\"description\":\"description\",\"test_attr1\":[\"value1\"],\"test_feature_type\":\"feature_value\",\"updated_at\":\"" + + updatedAt + + "\"}", + builder.toString() + ); + } +} diff --git a/server/src/test/java/org/opensearch/autotagging/RuleValidatorTests.java b/server/src/test/java/org/opensearch/autotagging/RuleValidatorTests.java new file mode 100644 index 0000000000000..8fbf16cd34c52 --- /dev/null +++ b/server/src/test/java/org/opensearch/autotagging/RuleValidatorTests.java @@ -0,0 +1,120 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.autotagging; + +import org.opensearch.test.OpenSearchTestCase; + +import java.time.Instant; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.opensearch.autotagging.RuleTests.ATTRIBUTE_MAP; +import static org.opensearch.autotagging.RuleTests.DESCRIPTION; +import static org.opensearch.autotagging.RuleTests.FEATURE_TYPE; +import static org.opensearch.autotagging.RuleTests.FEATURE_VALUE; +import static org.opensearch.autotagging.RuleTests.TestAttribute.TEST_ATTRIBUTE_1; +import static org.opensearch.autotagging.RuleTests.UPDATED_AT; + +public class RuleValidatorTests extends OpenSearchTestCase { + + public void testValidRule() { + RuleValidator validator = new RuleValidator(DESCRIPTION, ATTRIBUTE_MAP, FEATURE_VALUE, UPDATED_AT, FEATURE_TYPE); + try { + validator.validate(); + } catch (Exception e) { + fail("Expected no exception to be thrown, but got: " + e.getClass().getSimpleName()); + } + } + + public static void validateRule( + String featureValue, + T featureType, + Map> attributeMap, + String updatedAt, + String description + ) { + RuleValidator validator = new RuleValidator(description, attributeMap, featureValue, updatedAt, featureType); + validator.validate(); + } + + public void testInvalidDescription() { + assertThrows(IllegalArgumentException.class, () -> validateRule(FEATURE_VALUE, FEATURE_TYPE, ATTRIBUTE_MAP, UPDATED_AT, "")); + assertThrows(IllegalArgumentException.class, () -> validateRule(FEATURE_VALUE, FEATURE_TYPE, ATTRIBUTE_MAP, UPDATED_AT, null)); + assertThrows( + IllegalArgumentException.class, + () -> validateRule( + FEATURE_VALUE, + FEATURE_TYPE, + ATTRIBUTE_MAP, + UPDATED_AT, + randomAlphaOfLength(RuleValidator.MAX_DESCRIPTION_LENGTH + 1) + ) + ); + } + + public void testInvalidUpdateTime() { + assertThrows(IllegalArgumentException.class, () -> validateRule(FEATURE_VALUE, FEATURE_TYPE, ATTRIBUTE_MAP, null, DESCRIPTION)); + } + + public void testNullOrEmptyAttributeMap() { + assertThrows( + IllegalArgumentException.class, + () -> validateRule(FEATURE_VALUE, FEATURE_TYPE, new HashMap<>(), Instant.now().toString(), DESCRIPTION) + ); + assertThrows( + IllegalArgumentException.class, + () -> validateRule(FEATURE_VALUE, FEATURE_TYPE, null, Instant.now().toString(), DESCRIPTION) + ); + } + + public void testInvalidAttributeMap() { + Map> map = new HashMap<>(); + Attribute attribute = TEST_ATTRIBUTE_1; + map.put(attribute, Set.of("")); + assertThrows( + IllegalArgumentException.class, + () -> validateRule(FEATURE_VALUE, FEATURE_TYPE, map, Instant.now().toString(), DESCRIPTION) + ); + + map.put(attribute, Set.of(randomAlphaOfLength(FEATURE_TYPE.getMaxCharLengthPerAttributeValue() + 1))); + assertThrows( + IllegalArgumentException.class, + () -> validateRule(FEATURE_VALUE, FEATURE_TYPE, map, Instant.now().toString(), DESCRIPTION) + ); + + map.put(attribute, new HashSet<>()); + for (int i = 0; i < FEATURE_TYPE.getMaxNumberOfValuesPerAttribute() + 1; i++) { + map.get(attribute).add(String.valueOf(i)); + } + assertThrows( + IllegalArgumentException.class, + () -> validateRule(FEATURE_VALUE, FEATURE_TYPE, map, Instant.now().toString(), DESCRIPTION) + ); + } + + public void testInvalidFeature() { + assertThrows( + IllegalArgumentException.class, + () -> validateRule(FEATURE_VALUE, null, new HashMap<>(), Instant.now().toString(), DESCRIPTION) + ); + } + + public void testInvalidLabel() { + assertThrows(IllegalArgumentException.class, () -> validateRule(null, FEATURE_TYPE, ATTRIBUTE_MAP, UPDATED_AT, DESCRIPTION)); + assertThrows(IllegalArgumentException.class, () -> validateRule("", FEATURE_TYPE, ATTRIBUTE_MAP, UPDATED_AT, DESCRIPTION)); + } + + public void testEqualRuleValidators() { + RuleValidator validator = new RuleValidator(DESCRIPTION, ATTRIBUTE_MAP, FEATURE_VALUE, UPDATED_AT, FEATURE_TYPE); + RuleValidator otherValidator = new RuleValidator(DESCRIPTION, ATTRIBUTE_MAP, FEATURE_VALUE, UPDATED_AT, FEATURE_TYPE); + assertEquals(validator, otherValidator); + } +}