diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/properties/EnumGuacamoleProperty.java b/guacamole-ext/src/main/java/org/apache/guacamole/properties/EnumGuacamoleProperty.java new file mode 100644 index 000000000..255e4b09a --- /dev/null +++ b/guacamole-ext/src/main/java/org/apache/guacamole/properties/EnumGuacamoleProperty.java @@ -0,0 +1,254 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.guacamole.properties; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.GuacamoleServerException; + +/** + * A GuacamoleProperty whose possible values are defined by an enum. Possible + * values may be defined either through providing an explicit mapping or + * through annotating the enum constant definitions with the + * {@link PropertyValue} annotation. + * + * @param + * The enum which defines the possible values of this property. + */ +public abstract class EnumGuacamoleProperty> implements GuacamoleProperty { + + /** + * Defines the string value which should be accepted and parsed into the + * annotated enum constant. + */ + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.FIELD) + public static @interface PropertyValue { + + /** + * Returns the String value that should produce the annotated enum + * constant when parsed. + * + * @return + * The String value that should produce the annotated enum constant + * when parsed. + */ + String value(); + + } + + /** + * Mapping of valid property values to the corresponding enum constants + * that those values parse to. + */ + private final Map valueMapping; + + /** + * Produces a mapping of Guacamole property value to corresponding enum + * constant. All enum constants annotated with {@link PropertyValue} are + * included in the resulting Map. + * + * @param + * The enum for which a value mapping is being produced. + * + * @param enumClass + * The Class of the enum for which a value mapping is being produced. + * + * @return + * A new Map which associates the Guacamole property string values of + * enum constants (as defined by {@link PropertyValue} annotations) + * with their corresponding enum constants. + */ + private static > Map getValueMapping(Class enumClass) { + + T[] values = enumClass.getEnumConstants(); + Map valueMapping = new HashMap<>(values.length); + + for (T value : values) { + + // Retrieve Field which corresponds to the current enum constant + Field field; + try { + field = enumClass.getDeclaredField(value.name()); + } + catch (NoSuchFieldException e) { + // This SHOULD be impossible + throw new IllegalStateException("Fields of enum do not " + + "match declared values.", e); + } + + // Map enum constant only if PropertyValue annotation is present + PropertyValue valueAnnotation = field.getAnnotation(PropertyValue.class); + if (valueAnnotation != null) + valueMapping.put(valueAnnotation.value(), value); + + } + + return valueMapping; + + } + + /** + * Produces a new Map having the given key/value pairs. Each key MUST be a + * String, and each value MUST be an enum constant belonging to the given + * enum. + * + * @param + * The enum whose constants may be used as values within the Map. + * + * @param key + * The key of the first key/value pair to include within the Map. + * + * @param value + * The value of the first key/value pair to include within the Map. + * + * @param additional + * Any additional key/value pairs to be included beyond the first. This + * array must be even in length, where each even element is a String + * key and each odd element is the enum constant value to be associated + * with the key immediately preceding it. + * + * @return + * A new Map having each of the given key/value pairs. + * + * @throws IllegalArgumentException + * If any provided key is not a String, if any provided value is not + * an enum constant from the given enum type, or if the length of + * {@code additional} is not even. + */ + @SuppressWarnings("unchecked") // We check this ourselves with instanceof and getDeclaringClass() + private static > Map mapOf(String key, T value, + Object... additional) throws IllegalArgumentException { + + // Verify length of additional pairs is even + if (additional.length % 2 != 0) + throw new IllegalArgumentException("Array of additional key/value pairs must be even in length."); + + // Add first type-checked pair + Map valueMapping = new HashMap<>(1 + additional.length); + valueMapping.put(key, value); + + Class enumClass = value.getDeclaringClass(); + + // Add remaining, unchecked pairs + for (int i = 0; i < additional.length; i += 2) { + + // Verify that unchecked keys are indeed Strings + Object additionalKey = additional[i]; + if (!(additionalKey instanceof String)) + throw new IllegalArgumentException("Keys of additional key/value pairs must be strings."); + + // Verify that unchecked values are indeed constants defined by the + // expected enum + Object additionalValue = additional[i + 1]; + if (!(additionalValue instanceof Enum) || enumClass != ((Enum) additionalValue).getDeclaringClass()) + throw new IllegalArgumentException("Values of additional key/value pairs must be enum constants of the correct type."); + + valueMapping.put((String) additionalKey, (T) additionalValue); + + } + + return valueMapping; + + } + + /** + * Creates a new EnumGuacamoleProperty which parses String property values + * into corresponding enum constants as defined by the given Map. + * + * @param valueMapping + * A Map which maps all legal String values to their corresponding enum + * constants. + */ + public EnumGuacamoleProperty(Map valueMapping) { + this.valueMapping = valueMapping; + } + + /** + * Creates a new EnumGuacamoleProperty which parses String property values + * into corresponding enum constants as defined by the + * {@link PropertyValue} annotations associated with those constants. + * + * @param enumClass + * The enum whose annotated constants should be used as legal values of + * this property. + */ + public EnumGuacamoleProperty(Class enumClass) { + this(getValueMapping(enumClass)); + } + + /** + * Creates a new EnumGuacamoleProperty which parses the given String + * property values into the given corresponding enum constants. + * + * @param key + * The first String value to accept as a legal value of this property. + * + * @param value + * The enum constant that {@code key} should be parsed into. + * + * @param additional + * Any additional key/value pairs to be included beyond the first. This + * array must be even in length, where each even element is a String + * key and each odd element is the enum constant value to be associated + * with the key immediately preceding it. + * + * @throws IllegalArgumentException + * If any provided key is not a String, if any provided value is not + * an enum constant from the given enum type, or if the length of + * {@code additional} is not even. + */ + public EnumGuacamoleProperty(String key, T value, Object... additional) + throws IllegalArgumentException { + this(mapOf(key, value, additional)); + } + + @Override + public T parseValue(String value) throws GuacamoleException { + + // Simply pass through null values + if (value == null) + return null; + + // Translate values based on explicit string/constant mapping + T parsedValue = valueMapping.get(value); + if (parsedValue != null) + return parsedValue; + + // Produce human-readable error if no matching constant is found + List legalValues = new ArrayList<>(valueMapping.keySet()); + Collections.sort(legalValues); + + throw new GuacamoleServerException(String.format("\"%s\" is not a " + + "valid value for property \"%s\". Valid values are: \"%s\"", + value, getName(), String.join("\", \"", legalValues))); + + } + +} diff --git a/guacamole-ext/src/test/java/org/apache/guacamole/properties/EnumGuacamolePropertyTest.java b/guacamole-ext/src/test/java/org/apache/guacamole/properties/EnumGuacamolePropertyTest.java new file mode 100644 index 000000000..5b9a3d742 --- /dev/null +++ b/guacamole-ext/src/test/java/org/apache/guacamole/properties/EnumGuacamolePropertyTest.java @@ -0,0 +1,372 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.guacamole.properties; + +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.properties.EnumGuacamoleProperty.PropertyValue; +import static org.junit.Assert.*; +import org.junit.Test; + +/** + * Test which verifies that EnumGuacamoleProperty functions correctly. + */ +public class EnumGuacamolePropertyTest { + + /** + * Example enum consisting of a small set of possible fish. All values of + * this enum are annotated with {@link PropertyValue}. + */ + public static enum Fish { + + /** + * Salmon are large, anadromous fish prized for their pink/red/orange + * flesh. + * + * @see Salmon (Wikipedia) + */ + @PropertyValue("salmon") + SALMON, + + /** + * Trout are freshwater fish related to salmon, popular both as food + * and as game fish. + * + * @see Trout (Wikipedia) + */ + @PropertyValue("trout") + TROUT, + + /** + * Mackerel are pelagic fish, typically having vertical stripes along + * their backs. + * + * @see Mackerel (Wikipedia) + */ + @PropertyValue("mackerel") + MACKEREL, + + /** + * Tuna are large, predatory, saltwater fish in the same family as + * mackerel. They are one of the few fish that can maintain a body + * temperature higher than the surrounding water. + * + * @see Tuna (Wikipedia) + */ + @PropertyValue("tuna") + TUNA, + + /** + * Sardines are small, herring-like fish commonly served in cans. + * Sardines are considered prey fish and feed almost exclusively on + * zooplankton. + * + * @see Sardine (Wikipedia) + */ + @PropertyValue("sardine") + SARDINE + + } + + /** + * Example enum consisting of a small set of possible vegetables. None of + * the values of this enum are annotated with {@link PropertyValue}. + */ + public static enum Vegetable { + + /** + * Potatoes are starchy root vegetables native to the Americas. The + * tuber itself is edible, but other parts can be toxic. + * + * @see Potato (Wikipedia) + */ + POTATO, + + /** + * Carrots are root vegetables, tapered in shape and generally orange + * in color. + * + * @see Carrot (Wikipedia) + */ + CARROT + + } + + /** + * Example Guacamole property which parses String values as Fish constants. + */ + private static final EnumGuacamoleProperty FAVORITE_FISH = new EnumGuacamoleProperty(Fish.class) { + + @Override + public String getName() { + return "favorite-fish"; + } + + }; + + /** + * Verifies that EnumGuacamoleProperty correctly parses string values that + * are associated with their corresponding enum constants using the + * {@link PropertyValue} annotation. + * + * @throws GuacamoleException + * If a valid test value is incorrectly recognized by parseValue() as + * invalid. + */ + @Test + public void testParseValue() throws GuacamoleException { + assertEquals(Fish.SALMON, FAVORITE_FISH.parseValue("salmon")); + assertEquals(Fish.TROUT, FAVORITE_FISH.parseValue("trout")); + assertEquals(Fish.MACKEREL, FAVORITE_FISH.parseValue("mackerel")); + assertEquals(Fish.TUNA, FAVORITE_FISH.parseValue("tuna")); + assertEquals(Fish.SARDINE, FAVORITE_FISH.parseValue("sardine")); + } + + /** + * Verifies that the absence of a property value (null) is parsed by + * EnumGuacamoleProperty as the absence of an enum constant (also null). + * + * @throws GuacamoleException + * If a valid test value is incorrectly recognized by parseValue() as + * invalid. + */ + @Test + public void testParseNullValue() throws GuacamoleException { + assertNull(FAVORITE_FISH.parseValue(null)); + } + + /** + * Verifies that GuacamoleException is thrown when attempting to parse an + * invalid value, and that the error message contains a sorted list of all + * allowed values. + */ + @Test + public void testParseInvalidValue() { + try { + FAVORITE_FISH.parseValue("anchovy"); + fail("Invalid EnumGuacamoleProperty values should fail to parse with an exception."); + } + catch (GuacamoleException e) { + String message = e.getMessage(); + assertTrue(message.contains("\"mackerel\", \"salmon\", \"sardine\", \"trout\", \"tuna\"")); + } + } + + /** + * Verifies that EnumGuacamoleProperty can be constructed for enums that + * are not annotated with {@link PropertyValue}. + * + * @throws GuacamoleException + * If a valid test value is incorrectly recognized by parseValue() as + * invalid. + */ + @Test + public void testUnannotatedEnum() throws GuacamoleException { + + EnumGuacamoleProperty favoriteVegetable = new EnumGuacamoleProperty( + "potato", Vegetable.POTATO, + "carrot", Vegetable.CARROT + ) { + + @Override + public String getName() { + return "favorite-vegetable"; + } + + }; + + assertEquals(Vegetable.POTATO, favoriteVegetable.parseValue("potato")); + assertEquals(Vegetable.CARROT, favoriteVegetable.parseValue("carrot")); + + } + + /** + * Verifies that an IllegalArgumentException is thrown if key/value pairs + * are provided in the wrong order (value followed by key instead of key + * followed by value). + */ + @Test + public void testUnannotatedEnumBadOrder() { + + try { + + new EnumGuacamoleProperty( + "potato", Vegetable.POTATO, + Vegetable.CARROT, "carrot" + ) { + + @Override + public String getName() { + return "favorite-vegetable"; + } + + }; + + fail("EnumGuacamoleProperty should not accept key/value pairs in value/key order."); + + } + catch (IllegalArgumentException e) { + // Success + } + + } + + /** + * Verifies that an IllegalArgumentException is thrown if constants from + * the wrong enum are provided in an explicit mapping. + */ + @Test + public void testUnannotatedEnumBadValue() { + + try { + + new EnumGuacamoleProperty( + "potato", Vegetable.POTATO, + "carrot", Fish.TROUT + ) { + + @Override + public String getName() { + return "favorite-vegetable"; + } + + }; + + fail("EnumGuacamoleProperty should not accept values from the wrong enum."); + + } + catch (IllegalArgumentException e) { + // Success + } + + } + + /** + * Verifies that an IllegalArgumentException is thrown if non-String keys + * are provided in an explicit mapping. + */ + @Test + public void testUnannotatedEnumBadKey() { + + try { + + new EnumGuacamoleProperty( + "potato", Vegetable.POTATO, + 1, Vegetable.CARROT + ) { + + @Override + public String getName() { + return "favorite-vegetable"; + } + + }; + + fail("EnumGuacamoleProperty should not accept keys that are not Strings."); + + } + catch (IllegalArgumentException e) { + // Success + } + + } + + /** + * Verifies that an IllegalArgumentException is thrown if the length of the + * {@code additional} array is not even. + */ + @Test + public void testUnannotatedEnumBadLength() { + + try { + + new EnumGuacamoleProperty( + "potato", Vegetable.POTATO, + 1, Vegetable.CARROT, 2 + ) { + + @Override + public String getName() { + return "favorite-vegetable"; + } + + }; + + fail("EnumGuacamoleProperty should not accept additional key/value pairs from an array that is not even in length."); + + } + catch (IllegalArgumentException e) { + // Success + } + + } + + /** + * Verifies that explicit string/constant mappings take priority over the + * {@link PropertyValue} annotation when both are used. + * + * @throws GuacamoleException + * If a valid test value is incorrectly recognized by parseValue() as + * invalid. + */ + @Test + public void testAnnotationPrecedence() throws GuacamoleException { + + EnumGuacamoleProperty favoriteFish = new EnumGuacamoleProperty( + "chinook", Fish.SALMON, + "rainbow", Fish.TROUT + ) { + + @Override + public String getName() { + return "favorite-fish"; + } + + }; + + assertEquals(Fish.SALMON, favoriteFish.parseValue("chinook")); + assertEquals(Fish.TROUT, favoriteFish.parseValue("rainbow")); + + try { + favoriteFish.parseValue("salmon"); + fail("Explicit key/value mapping should take priority over annotations."); + } + catch (GuacamoleException e) { + // Success + } + + try { + favoriteFish.parseValue("trout"); + fail("Explicit key/value mapping should take priority over annotations."); + } + catch (GuacamoleException e) { + // Success + } + + try { + favoriteFish.parseValue("tuna"); + fail("Annotations should not have any effect if explicit key/value mapping is used."); + } + catch (GuacamoleException e) { + // Success + } + + } + +}