// Copyright 2016 The Domain Registry 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 com.google.common.base.Ascii; import com.google.common.base.CharMatcher; import com.google.common.base.Function; import com.google.common.base.Functions; import com.google.common.base.Optional; import com.google.common.base.Splitter; import com.google.common.collect.FluentIterable; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Range; import com.google.re2j.Pattern; import java.util.Collection; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import javax.annotation.Detainted; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.Tainted; import javax.annotation.concurrent.Immutable; /** * Declarative functional fluent form field converter / validator. * *

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:

 *
 *   private enum Gender { MALE, FEMALE }
 *
 *   private static final FormField NAME_FIELD = FormField.named("name")
 *       .matches("[a-z]+")
 *       .range(atMost(16))
 *       .required()
 *       .build();
 *
 *   private static final FormField GENDER_FIELD = FormField.named("gender")
 *       .asEnum(Gender.class)
 *       .required()
 *       .build();
 *
 *   public Person makePerson(Map 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();
 *   }
* *

This class provides full type-safety if and only if you statically initialize * your FormField objects and write a unit test that causes the class to be loaded. * *

Exception Handling

* *

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. * *

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. * *

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]}. * *

Library Definitions

* *

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)}. * *

Here is an example of how you might go about defining library definitions:

 *
 *   final class FormFields {
 *     private static final FormField COUNTRY_CODE =
 *         FormField.named("countryCode")
 *             .range(Range.singleton(2))
 *             .uppercased()
 *             .in(ImmutableSet.copyOf(Locale.getISOCountries()))
 *             .build();
 *   }
 *
 *   final class Form {
 *     private static final FormField COUNTRY_CODE_FIELD =
 *         FormFields.COUNTRY_CODE.asBuilder()
 *             .required()
 *             .build();
 *   }
* * @param input value type * @param output value type */ @Immutable public final class FormField { private final String name; private final Class typeIn; private final Class typeOut; private final Function converter; private FormField(String name, Class typeIn, Class typeOut, Function 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 named(String name) { return named(name, String.class); } /** Returns an optional form field named {@code name} with a specific {@code inputType}. */ public static Builder named(String name, Class typeIn) { checkArgument(!name.isEmpty()); return new Builder<>(name, checkNotNull(typeIn), typeIn, Functions.identity()); } /** * Returns a form field builder for validating JSON nested maps. * *

Here's an example of how you'd use this feature: * *

   *   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();
* *

When a {@link FormFieldException} is thrown, it'll be propagated to create a fully-qualified * field name. For example, if the JSON input is

{registrar: {name: ""}}
then the * {@link FormFieldException#getFieldName() field name} will be {@code registrar.name}. */ public static Builder, Map> mapNamed(String name) { @SuppressWarnings("unchecked") Class> typeIn = (Class>) (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 convert(@Tainted @Nullable I value) { try { return Optional.fromNullable(converter.apply(value)); } catch (FormFieldException e) { throw e.propagate(name); } } /** * Convert and validate a raw user-supplied value from a map. * *

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 extract(@Tainted Map 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 extractUntyped(@Tainted Map 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 asBuilder() { return new Builder<>(name, typeIn, typeOut, converter); } /** Same as {@link #asBuilder()} but changes the field name. */ public Builder asBuilderNamed(String newName) { checkArgument(!newName.isEmpty()); return new Builder<>(newName, typeIn, typeOut, converter); } /** * Mutable builder for {@link FormField}. * * @param input value type * @param output value type */ public static final class Builder { private final String name; private final Class typeIn; private final Class typeOut; private Function converter; private Builder(String name, Class typeIn, Class typeOut, Function 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 withDefault(O defaultValue) { return transform(new DefaultFunction<>(checkNotNull(defaultValue))); } /** Ensure value is not {@code null}. */ public Builder required() { @SuppressWarnings("unchecked") Function requiredFunction = (Function) REQUIRED_FUNCTION; return transform(requiredFunction); } /** * Transform empty values into {@code null}. * * @throws IllegalStateException if current output type is not a {@link CharSequence} or * {@link Collection}. */ public Builder emptyToNull() { checkState(CharSequence.class.isAssignableFrom(typeOut) || Collection.class.isAssignableFrom(typeOut)); @SuppressWarnings("unchecked") Function emptyToNullFunction = (Function) EMPTY_TO_NULL_FUNCTION; return transform(emptyToNullFunction); } /** * Modify {@link String} input to remove whitespace around the sides. * *

{@code null} values are passed through. * * @throws IllegalStateException if current output type is not a String. */ public Builder trimmed() { checkState(String.class.isAssignableFrom(typeOut)); @SuppressWarnings("unchecked") Function trimFunction = (Function) TRIM_FUNCTION; return transform(String.class, trimFunction); } /** * Modify {@link String} input to be uppercase. * *

{@code null} values are passed through. * * @throws IllegalStateException if current output type is not a String. */ public Builder uppercased() { checkState(String.class.isAssignableFrom(typeOut)); @SuppressWarnings("unchecked") Function funk = (Function) UPPERCASE_FUNCTION; return transform(String.class, funk); } /** * Modify {@link String} input to be lowercase. * *

{@code null} values are passed through. * * @throws IllegalStateException if current output type is not a String. */ public Builder lowercased() { checkState(String.class.isAssignableFrom(typeOut)); @SuppressWarnings("unchecked") Function funk = (Function) LOWERCASE_FUNCTION; return transform(String.class, funk); } /** * Ensure input matches {@code pattern}. * *

{@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 matches(Pattern pattern, @Nullable String errorMessage) { checkState(CharSequence.class.isAssignableFrom(typeOut)); return transform( new MatchesFunction(checkNotNull(pattern), Optional.fromNullable(errorMessage))); } /** Alias for {@link #matches(Pattern, String) matches(pattern, null)} */ public Builder matches(Pattern pattern) { return matches(pattern, null); } /** * Removes all characters not in {@code matcher}. * *

{@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 retains(CharMatcher matcher) { checkState(CharSequence.class.isAssignableFrom(typeOut)); @SuppressWarnings("unchecked") // safe due to checkState call Function function = (Function) new RetainFunction(checkNotNull(matcher)); return transform(String.class, function); } /** * Enforce value length/size/value is within {@code range}. * *

The following input value types are supported: * *

    *
  • {@link CharSequence}: Length must be within {@code range}. *
  • {@link Collection}: Size must be within {@code range}. *
  • {@link Number}: Value must be within {@code range}. *
* *

{@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 range(Range 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}. * *

{@code null} values are passed through. * * @throws IllegalArgumentException if {@code values} is empty. */ public Builder in(Set values) { checkArgument(!values.isEmpty()); return transform(new InFunction<>(values)); } /** * Performs arbitrary type transformation from {@code O} to {@code T}. * *

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}. * *

Here's an example of how you'd convert from String to Integer: * *

     *   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();
* * @see #transform(Function) */ public Builder transform(Class newType, Function transform) { return new Builder<>(name, typeIn, checkNotNull(newType), Functions.compose(checkNotNull(transform), converter)); } /** * Manipulates values without changing type. * *

Please see {@link #transform(Class, Function)} for information about the contract to * which {@code transform} is expected to conform. */ public Builder transform(Function transform) { this.converter = Functions.compose(checkNotNull(transform), converter); return this; } /** * Uppercases value and converts to an enum field of {@code enumClass}. * *

{@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 > Builder asEnum(Class enumClass) { checkArgument(enumClass.isEnum()); checkState(String.class.isAssignableFrom(typeOut)); return transform(enumClass, new ToEnumFunction(enumClass)); } /** * Turns this form field into something that processes lists. * *

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]}. * *

The outputted list will be an {@link ImmutableList}. This is not reflected in the generic * typing for the sake of brevity. * *

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> asList() { @SuppressWarnings("unchecked") Class> in = (Class>) (Class) List.class; @SuppressWarnings("unchecked") Class> out = (Class>) (Class) 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. * *

The behavior of this method is counter-intuitive. It behaves similar to {@link #asList()} * in the sense that all transforms specified before this method will be applied to the * individual resulting list items. * *

For example, to turn a comma-delimited string into an enum list:

   {@code
     *
     *   private static final FormField> STATES_FIELD =
     *       FormField.named("states")
     *            .uppercased()
     *            .asEnum(State.class)
     *            .asList(Splitter.on(',').omitEmptyStrings().trimResults())
     *            .build();}
* *

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 not contain the index. * * @throws IllegalStateException If either the current input type isn't String. */ public Builder> asList(Splitter splitter) { checkNotNull(splitter); checkState(String.class.isAssignableFrom(typeIn)); @SuppressWarnings("unchecked") Class> out = (Class>) (Class) List.class; @SuppressWarnings("unchecked") FormField inField = (FormField) 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, Set> asSet() { checkState(!List.class.isAssignableFrom(typeOut)); @SuppressWarnings("unchecked") Class> setOut = (Class>) (Class) Set.class; @SuppressWarnings("unchecked") Function, Set> toSetFunction = (Function, Set>) (Function) TO_SET_FUNCTION; 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> asSet(Splitter splitter) { checkNotNull(splitter); checkState(String.class.isAssignableFrom(typeIn)); @SuppressWarnings("unchecked") Class> out = (Class>) (Class) Set.class; @SuppressWarnings("unchecked") FormField inField = (FormField) build(); return new Builder<>(name, String.class, out, new SplitToSetFunction<>(inField, splitter)); } /** Creates a new {@link FormField} instance. */ public FormField build() { return new FormField<>(name, typeIn, typeOut, converter); } private static final Function, Set> TO_SET_FUNCTION = new Function, Set>() { @Nullable @Override public Set apply(@Nullable List input) { return input != null ? ImmutableSet.copyOf(input) : null; }}; private static final Function TRIM_FUNCTION = new Function() { @Nullable @Override public String apply(@Nullable String input) { return input != null ? input.trim() : null; }}; private static final Function UPPERCASE_FUNCTION = new Function() { @Nullable @Override public String apply(@Nullable String input) { return input != null ? input.toUpperCase(Locale.ENGLISH) : null; }}; private static final Function LOWERCASE_FUNCTION = new Function() { @Nullable @Override public String apply(@Nullable String input) { return input != null ? input.toLowerCase(Locale.ENGLISH) : null; }}; private static final Function REQUIRED_FUNCTION = new Function() { @Nonnull @Override public Object apply(@Nullable Object input) { if (input == null) { throw new FormFieldException("This field is required."); } return input; }}; private static final Function EMPTY_TO_NULL_FUNCTION = new Function() { @Nullable @Override public Object apply(@Nullable Object input) { return ((input instanceof CharSequence) && (((CharSequence) input).length() == 0)) || ((input instanceof Collection) && ((Collection) input).isEmpty()) ? null : input; }}; private static final class DefaultFunction implements Function { 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 implements Function { private final Range range; RangeFunction(Range 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 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 implements Function { private final Set values; InFunction(Set 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 implements Function { private final Pattern pattern; private final Optional errorMessage; MatchesFunction(Pattern pattern, Optional 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.or("Must match pattern: " + pattern)); } return input; } } private static final class RetainFunction implements Function { 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> implements Function { private final Class enumClass; ToEnumFunction(Class 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 implements Function, List> { private final FormField itemField; ToListFunction(FormField itemField) { this.itemField = itemField; } @Nullable @Override public List apply(@Nullable List input) { if (input == null) { return null; } ImmutableList.Builder 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 implements Function> { private final FormField itemField; private final Splitter splitter; SplitToListFunction(FormField itemField, Splitter splitter) { this.itemField = itemField; this.splitter = splitter; } @Nullable @Override public List apply(@Nullable String input) { return input == null ? null : FluentIterable .from(splitter.split(input)) .transform(itemField.converter) .toList(); } } private static final class SplitToSetFunction implements Function> { private final FormField itemField; private final Splitter splitter; SplitToSetFunction(FormField itemField, Splitter splitter) { this.itemField = itemField; this.splitter = splitter; } @Nullable @Override public Set apply(@Nullable String input) { return input == null ? null : FluentIterable .from(splitter.split(input)) .transform(itemField.converter) .toSet(); } } } }