// Copyright 2017 The Nomulus Authors. All Rights Reserved. // // Licensed 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 google.registry.ui.forms; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.collect.ImmutableSet.toImmutableSet; import com.google.common.base.Ascii; import com.google.common.base.CharMatcher; import com.google.common.base.Splitter; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Range; import com.google.common.collect.Streams; import com.google.re2j.Pattern; import java.util.Collection; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Function; import javax.annotation.Detainted; import javax.annotation.Nullable; import javax.annotation.Tainted; import javax.annotation.concurrent.Immutable; /** * Declarative functional fluent form field converter / validator. * * <p>This class is responsible for converting arbitrary data, sent to us by the web browser, into * validated data structures that the server-side code can use. For example:<pre> * * private enum Gender { MALE, FEMALE } * * private static final FormField<String, String> NAME_FIELD = FormField.named("name") * .matches("[a-z]+") * .range(atMost(16)) * .required() * .build(); * * private static final FormField<String, Gender> GENDER_FIELD = FormField.named("gender") * .asEnum(Gender.class) * .required() * .build(); * * public Person makePerson(Map<String, String> params) { * Person.Builder person = new Person.Builder(); * for (String name : NAME_FIELD.extract(params).asSet()) { * person.setName(name); * } * for (Gender name : GENDER_FIELD.extract(params).asSet()) { * person.setGender(name); * } * return person.build(); * }</pre> * * <p>This class provides <b>full type-safety</b> <i>if and only if</i> you statically initialize * your FormField objects and write a unit test that causes the class to be loaded. * * <h3>Exception Handling</h3> * * <p>When values passed to {@link #convert} or {@link #extract} don't meet the contract, * {@link FormFieldException} will be thrown, which provides the field name and a short error * message that's safe to pass along to the client. * * <p>You can safely throw {@code FormFieldException} from within your validator functions, and * the field name will automatically be propagated into the exception object for you. * * <p>In situations when you're validating lists or maps, you'll end up with a hierarchical field * naming structure. For example, if you were validating a list of maps, an error generated by the * {@code bar} field of the fifth item in the {@code foo} field would have a fully-qualified field * name of: {@code foo[5][bar]}. * * <h3>Library Definitions</h3> * * <p>You should never assign a partially constructed {@code FormField.Builder} to a variable or * constant. Instead, you should use {@link #asBuilder()} or {@link #asBuilderNamed(String)}. * * <p>Here is an example of how you might go about defining library definitions:<pre> * * final class FormFields { * private static final FormField<String, String> COUNTRY_CODE = * FormField.named("countryCode") * .range(Range.singleton(2)) * .uppercased() * .in(ImmutableSet.copyOf(Locale.getISOCountries())) * .build(); * } * * final class Form { * private static final FormField<String, String> COUNTRY_CODE_FIELD = * FormFields.COUNTRY_CODE.asBuilder() * .required() * .build(); * }</pre> * * @param <I> input value type * @param <O> output value type */ @Immutable public final class FormField<I, O> { private final String name; private final Class<I> typeIn; private final Class<O> typeOut; private final Function<I, O> converter; private FormField(String name, Class<I> typeIn, Class<O> typeOut, Function<I, O> converter) { this.name = name; this.typeIn = typeIn; this.typeOut = typeOut; this.converter = converter; } /** Returns an optional string form field named {@code name}. */ public static Builder<String, String> named(String name) { return named(name, String.class); } /** Returns an optional form field named {@code name} with a specific {@code inputType}. */ public static <T> Builder<T, T> named(String name, Class<T> typeIn) { checkArgument(!name.isEmpty()); return new Builder<>(name, checkNotNull(typeIn), typeIn, x -> x); } /** * Returns a form field builder for validating JSON nested maps. * * <p>Here's an example of how you'd use this feature: * * <pre> * private static final FormField<String, String> REGISTRAR_NAME_FIELD = * FormField.named("name") * .emptyToNull() * .required() * .build(); * * private static final FormField<Map<String, ?>, Registrar> REGISTRAR_FIELD = * FormField.mapNamed("registrar") * .transform(Registrar.class, new Function<Map<String, ?>, Registrar>() { * @Nullable * @Override * public Registrar apply(@Nullable Map<String, ?> params) { * Registrar.Builder builder = new Registrar.Builder(); * for (String name : REGISTRAR_NAME_FIELD.extractUntyped(params).asSet()) { * builder.setName(name); * } * return builder.build(); * }}) * .build();</pre> * * <p>When a {@link FormFieldException} is thrown, it'll be propagated to create a fully-qualified * field name. For example, if the JSON input is <pre>{registrar: {name: ""}}</pre> then the * {@link FormFieldException#getFieldName() field name} will be {@code registrar.name}. */ public static Builder<Map<String, ?>, Map<String, ?>> mapNamed(String name) { @SuppressWarnings("unchecked") Class<Map<String, ?>> typeIn = (Class<Map<String, ?>>) (Class<?>) Map.class; return named(name, typeIn); } /** Returns the name of this field. */ public String name() { return name; } /** * Convert and validate a raw user-supplied value. * * @throws FormFieldException if value does not meet expected contracts. */ @Detainted public Optional<O> convert(@Tainted @Nullable I value) { try { return Optional.ofNullable(converter.apply(value)); } catch (FormFieldException e) { throw e.propagate(name); } } /** * Convert and validate a raw user-supplied value from a map. * * <p>This is the same as saying: {@code field.convert(valueMap.get(field.name())} * * @throws FormFieldException if value does not meet expected contracts. */ @Detainted public Optional<O> extract(@Tainted Map<String, I> valueMap) { return convert(valueMap.get(name)); } /** * Convert and validate a raw user-supplied value from an untyped JSON map. * * @throws FormFieldException if value is wrong type or does not meet expected contracts. */ @Detainted public Optional<O> extractUntyped(@Tainted Map<String, ?> jsonMap) { Object value = jsonMap.get(name); I castedValue; try { castedValue = typeIn.cast(value); } catch (ClassCastException e) { throw new FormFieldException(String.format("Type error: got: %s, expected: %s", value.getClass().getSimpleName(), typeIn.getSimpleName())).propagate(name); } return convert(castedValue); } /** * Returns a builder of this object, which can be used to further restrict validation. * * @see #asBuilderNamed(String) */ public Builder<I, O> asBuilder() { return new Builder<>(name, typeIn, typeOut, converter); } /** Same as {@link #asBuilder()} but changes the field name. */ public Builder<I, O> asBuilderNamed(String newName) { checkArgument(!newName.isEmpty()); return new Builder<>(newName, typeIn, typeOut, converter); } /** * Mutable builder for {@link FormField}. * * @param <I> input value type * @param <O> output value type */ public static final class Builder<I, O> { private final String name; private final Class<I> typeIn; private final Class<O> typeOut; private Function<I, O> converter; private Builder(String name, Class<I> typeIn, Class<O> typeOut, Function<I, O> converter) { this.name = name; this.typeIn = typeIn; this.typeOut = typeOut; this.converter = converter; } /** Causes {@code defaultValue} to be substituted if value is {@code null}. */ public Builder<I, O> withDefault(O defaultValue) { return transform(new DefaultFunction<>(checkNotNull(defaultValue))); } /** Ensure value is not {@code null}. */ public Builder<I, O> required() { return transform(Builder::checkNotNullTransform); } /** * Transform empty values into {@code null}. * * @throws IllegalStateException if current output type is not a {@link CharSequence} or * {@link Collection}. */ public Builder<I, O> emptyToNull() { checkState(CharSequence.class.isAssignableFrom(typeOut) || Collection.class.isAssignableFrom(typeOut)); return transform( input -> ((input instanceof CharSequence) && (((CharSequence) input).length() == 0)) || ((input instanceof Collection) && ((Collection<?>) input).isEmpty()) ? null : input); } /** * Modify {@link String} input to remove whitespace around the sides. * * <p>{@code null} values are passed through. * * @throws IllegalStateException if current output type is not a String. */ public Builder<I, String> trimmed() { checkState(String.class.isAssignableFrom(typeOut)); @SuppressWarnings("unchecked") Function<O, String> trimFunction = (Function<O, String>) ((Function<String, String>) input -> input != null ? input.trim() : null); return transform(String.class, trimFunction); } /** * Modify {@link String} input to be uppercase. * * <p>{@code null} values are passed through. * * @throws IllegalStateException if current output type is not a String. */ public Builder<I, String> uppercased() { checkState(String.class.isAssignableFrom(typeOut)); @SuppressWarnings("unchecked") Function<O, String> funk = (Function<O, String>) ((Function<String, String>) input -> input != null ? input.toUpperCase(Locale.ENGLISH) : null); return transform(String.class, funk); } /** * Modify {@link String} input to be lowercase. * * <p>{@code null} values are passed through. * * @throws IllegalStateException if current output type is not a String. */ public Builder<I, String> lowercased() { checkState(String.class.isAssignableFrom(typeOut)); @SuppressWarnings("unchecked") Function<O, String> funk = (Function<O, String>) ((Function<String, String>) input -> input != null ? input.toLowerCase(Locale.ENGLISH) : null); return transform(String.class, funk); } /** * Ensure input matches {@code pattern}. * * <p>{@code null} values are passed through. * * @param pattern is used to validate the user input. It matches against the whole string, so * you don't need to use the ^$ characters. * @param errorMessage is a helpful error message, which should include an example. If this is * not provided, a default error message will be shown that includes the regexp pattern. * @throws IllegalStateException if current output type is not a {@link CharSequence}. * @see #matches(Pattern) */ public Builder<I, O> matches(Pattern pattern, @Nullable String errorMessage) { checkState(CharSequence.class.isAssignableFrom(typeOut)); return transform( new MatchesFunction<>(checkNotNull(pattern), Optional.ofNullable(errorMessage))); } /** Alias for {@link #matches(Pattern, String) matches(pattern, null)} */ public Builder<I, O> matches(Pattern pattern) { return matches(pattern, null); } /** * Removes all characters not in {@code matcher}. * * <p>{@code null} values are passed through. * * @param matcher indicates which characters are to be retained * @throws IllegalStateException if current output type is not a {@link CharSequence} */ public Builder<I, String> retains(CharMatcher matcher) { checkState(CharSequence.class.isAssignableFrom(typeOut)); @SuppressWarnings("unchecked") // safe due to checkState call Function<O, String> function = (Function<O, String>) new RetainFunction(checkNotNull(matcher)); return transform(String.class, function); } /** * Enforce value length/size/value is within {@code range}. * * <p>The following input value types are supported: * * <ul> * <li>{@link CharSequence}: Length must be within {@code range}. * <li>{@link Collection}: Size must be within {@code range}. * <li>{@link Number}: Value must be within {@code range}. * </ul> * * <p>{@code null} values are passed through. Please note that setting a lower bound on your * range does not imply {@link #required()}, as range checking only applies to non-{@code null} * values. * * @throws IllegalStateException if current output type is not one of the above types. */ public Builder<I, O> range(Range<Integer> range) { checkState(CharSequence.class.isAssignableFrom(typeOut) || Collection.class.isAssignableFrom(typeOut) || Number.class.isAssignableFrom(typeOut)); return transform(new RangeFunction<>(checkNotNull(range))); } /** * Enforce value be a member of {@code values}. * * <p>{@code null} values are passed through. * * @throws IllegalArgumentException if {@code values} is empty. */ public Builder<I, O> in(Set<O> values) { checkArgument(!values.isEmpty()); return transform(new InFunction<>(values)); } /** * Performs arbitrary type transformation from {@code O} to {@code T}. * * <p>Your {@code transform} function is expected to pass-through {@code null} values as a * no-op, since it's up to {@link #required()} to block them. You might also want to consider * using a try block that rethrows exceptions as {@link FormFieldException}. * * <p>Here's an example of how you'd convert from String to Integer: * * <pre> * FormField.named("foo", String.class) * .transform(Integer.class, new Function<String, Integer>() { * @Nullable * @Override * public Integer apply(@Nullable String input) { * try { * return input != null ? Integer.parseInt(input) : null; * } catch (IllegalArgumentException e) { * throw new FormFieldException("Invalid number.", e); * } * }}) * .build();</pre> * * @see #transform(Function) */ public <T> Builder<I, T> transform(Class<T> newType, Function<O, T> transform) { return new Builder<>( name, typeIn, checkNotNull(newType), this.converter.andThen(checkNotNull(transform))); } /** * Manipulates values without changing type. * * <p>Please see {@link #transform(Class, Function)} for information about the contract to * which {@code transform} is expected to conform. */ public Builder<I, O> transform(Function<O, O> transform) { this.converter = this.converter.andThen(checkNotNull(transform)); return this; } /** * Uppercases value and converts to an enum field of {@code enumClass}. * * <p>{@code null} values are passed through. * * @throws IllegalArgumentException if {@code enumClass} is not an enum class. * @throws IllegalStateException if current output type is not a String. */ public <C extends Enum<C>> Builder<I, C> asEnum(Class<C> enumClass) { checkArgument(enumClass.isEnum()); checkState(String.class.isAssignableFrom(typeOut)); return transform(enumClass, new ToEnumFunction<>(enumClass)); } /** * Turns this form field into something that processes lists. * * <p>The current object definition will be applied to each item in the list. If a * {@link FormFieldException} is thrown when processing an item, then its * {@link FormFieldException#getFieldName() fieldName} will be rewritten to include the index, * e.g. {@code name} becomes {@code name[0]}. * * <p>The outputted list will be an {@link ImmutableList}. This is not reflected in the generic * typing for the sake of brevity. * * <p>A {@code null} value for list will be passed through. List items that convert to * {@code null} will be discarded (since {@code ImmutableList} does not permit {@code null} * values). */ public Builder<List<I>, List<O>> asList() { @SuppressWarnings("unchecked") Class<List<I>> in = (Class<List<I>>) (Class<I>) List.class; @SuppressWarnings("unchecked") Class<List<O>> out = (Class<List<O>>) (Class<O>) List.class; return new Builder<>(name, in, out, new ToListFunction<>(build())); } /** * Turns this form field into a split string list that applies itself to each item. * * <p>The behavior of this method is counter-intuitive. It behaves similar to {@link #asList()} * in the sense that all transforms specified <i>before</i> this method will be applied to the * individual resulting list items. * * <p>For example, to turn a comma-delimited string into an enum list:<pre> {@code * * private static final FormField<String, List<State>> STATES_FIELD = * FormField.named("states") * .uppercased() * .asEnum(State.class) * .asList(Splitter.on(',').omitEmptyStrings().trimResults()) * .build();}</pre> * * <p>You'll notice that the transforms specified before this method are applied to each list * item. However unlike {@link #asList()}, if an error is thrown on an individual item, then * {@link FormFieldException#getFieldName()} will <i>not</i> contain the index. * * @throws IllegalStateException If either the current input type isn't String. */ public Builder<String, List<O>> asList(Splitter splitter) { checkNotNull(splitter); checkState(String.class.isAssignableFrom(typeIn)); @SuppressWarnings("unchecked") Class<List<O>> out = (Class<List<O>>) (Class<O>) List.class; @SuppressWarnings("unchecked") FormField<String, O> inField = (FormField<String, O>) build(); return new Builder<>(name, String.class, out, new SplitToListFunction<>(inField, splitter)); } /** * Same as {@link #asList()} but outputs an {@link ImmutableSet} instead. * * @throws IllegalStateException if you called asList() before calling this method. */ public Builder<List<I>, Set<O>> asSet() { checkState(!List.class.isAssignableFrom(typeOut)); @SuppressWarnings("unchecked") Class<Set<O>> setOut = (Class<Set<O>>) (Class<O>) Set.class; @SuppressWarnings("unchecked") Function<List<O>, Set<O>> toSetFunction = (Function<List<O>, Set<O>>) (Function<O, O>) ((Function<List<Object>, Set<Object>>) input -> input != null ? ImmutableSet.copyOf(input) : null); return asList().transform(setOut, toSetFunction); } /** * Same as {@link #asList(Splitter)} but outputs an {@link ImmutableSet} instead. * * @throws IllegalStateException If the current input type isn't String. */ public Builder<String, Set<O>> asSet(Splitter splitter) { checkNotNull(splitter); checkState(String.class.isAssignableFrom(typeIn)); @SuppressWarnings("unchecked") Class<Set<O>> out = (Class<Set<O>>) (Class<O>) Set.class; @SuppressWarnings("unchecked") FormField<String, O> inField = (FormField<String, O>) build(); return new Builder<>(name, String.class, out, new SplitToSetFunction<>(inField, splitter)); } /** Creates a new {@link FormField} instance. */ public FormField<I, O> build() { return new FormField<>(name, typeIn, typeOut, converter); } private static <O> O checkNotNullTransform(@Nullable O input) { if (input == null) { throw new FormFieldException("This field is required."); } return input; } private static final class DefaultFunction<O> implements Function<O, O> { private final O defaultValue; DefaultFunction(O defaultValue) { this.defaultValue = defaultValue; } @Nullable @Override public O apply(@Nullable O input) { return input != null ? input : defaultValue; } } private static final class RangeFunction<O> implements Function<O, O> { private final Range<Integer> range; RangeFunction(Range<Integer> range) { this.range = range; } @Nullable @Override public O apply(@Nullable O input) { if (input == null) { return null; } if (input instanceof CharSequence) { checkRangeContains(range, ((CharSequence) input).length(), "Number of characters"); } else if (input instanceof Collection) { checkRangeContains(range, ((Collection<?>) input).size(), "Number of items"); } else if (input instanceof Number) { checkRangeContains(range, ((Number) input).intValue(), "Value"); } else { throw new AssertionError(); } return input; } private void checkRangeContains(Range<Integer> range, int value, String message) { if (!range.contains(value)) { throw new FormFieldException( String.format("%s (%,d) not in range %s", message, value, range)); } } } private static final class InFunction<O> implements Function<O, O> { private final Set<O> values; InFunction(Set<O> values) { this.values = values; } @Nullable @Override public O apply(@Nullable O input) { if (input == null) { return null; } if (!values.contains(input)) { throw new FormFieldException("Unrecognized value."); } return input; } } private static final class MatchesFunction<O> implements Function<O, O> { private final Pattern pattern; private final Optional<String> errorMessage; MatchesFunction(Pattern pattern, Optional<String> errorMessage) { this.pattern = pattern; this.errorMessage = errorMessage; } @Nullable @Override public O apply(@Nullable O input) { if (input == null) { return null; } if (!pattern.matcher((CharSequence) input).matches()) { throw new FormFieldException(errorMessage.orElse("Must match pattern: " + pattern)); } return input; } } private static final class RetainFunction implements Function<CharSequence, String> { private final CharMatcher matcher; RetainFunction(CharMatcher matcher) { this.matcher = matcher; } @Nullable @Override public String apply(@Nullable CharSequence input) { if (input == null) { return null; } return matcher.retainFrom(input); } } private static final class ToEnumFunction<O, C extends Enum<C>> implements Function<O, C> { private final Class<C> enumClass; ToEnumFunction(Class<C> enumClass) { this.enumClass = enumClass; } @Nullable @Override public C apply(@Nullable O input) { try { return input != null ? Enum.valueOf(enumClass, Ascii.toUpperCase((String) input)) : null; } catch (IllegalArgumentException e) { throw new FormFieldException( String.format("Enum %s does not contain '%s'", enumClass.getSimpleName(), input)); } } } private static final class ToListFunction<I, O> implements Function<List<I>, List<O>> { private final FormField<I, O> itemField; ToListFunction(FormField<I, O> itemField) { this.itemField = itemField; } @Nullable @Override public List<O> apply(@Nullable List<I> input) { if (input == null) { return null; } ImmutableList.Builder<O> builder = new ImmutableList.Builder<>(); for (int i = 0; i < input.size(); i++) { I inputItem = itemField.typeIn.cast(input.get(i)); O outputItem; try { outputItem = itemField.converter.apply(inputItem); } catch (FormFieldException e) { throw e.propagate(i); } if (outputItem != null) { builder.add(outputItem); } } return builder.build(); } } private static final class SplitToListFunction<O> implements Function<String, List<O>> { private final FormField<String, O> itemField; private final Splitter splitter; SplitToListFunction(FormField<String, O> itemField, Splitter splitter) { this.itemField = itemField; this.splitter = splitter; } @Nullable @Override public List<O> apply(@Nullable String input) { return input == null ? null : Streams.stream(splitter.split(input)) .map(itemField.converter) .collect(toImmutableList()); } } private static final class SplitToSetFunction<O> implements Function<String, Set<O>> { private final FormField<String, O> itemField; private final Splitter splitter; SplitToSetFunction(FormField<String, O> itemField, Splitter splitter) { this.itemField = itemField; this.splitter = splitter; } @Nullable @Override public Set<O> apply(@Nullable String input) { return input == null ? null : Streams.stream(splitter.split(input)) .map(itemField.converter) .collect(toImmutableSet()); } } } }