From 675f9e38952586d46d4b659824eb2f9df250b401 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Tue, 19 Jan 2021 22:17:29 +0100 Subject: [PATCH] #1439 - Support for HAL FORMS min, max, minLength and maxLength attributes. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce support for the min, max, minLength and maxLength fields for HAL FORMS property descriptors. Extended PropertyMetadata to capture those based on JSR-303 annotations. Pulled up InputPayloadMetadata.createProperties(…) into AffordanceModel directly. Deprecated InputPayloadMetadata.applyTo(…). --- pom.xml | 7 + .../hateoas/AffordanceModel.java | 88 ++++++--- .../hateoas/mediatype/PropertyUtils.java | 94 +++++++--- .../hal/forms/HalFormsAffordanceModel.java | 127 ------------- .../mediatype/hal/forms/HalFormsProperty.java | 160 +++++++++++++--- .../hal/forms/HalFormsPropertyFactory.java | 174 ++++++++++++++++++ .../hal/forms/HalFormsTemplateBuilder.java | 6 +- .../hateoas/support/ClassUtils.java | 36 ++++ .../HalFormsTemplateBuilderUnitTest.java | 39 +++- 9 files changed, 515 insertions(+), 216 deletions(-) create mode 100644 src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsPropertyFactory.java create mode 100644 src/main/java/org/springframework/hateoas/support/ClassUtils.java diff --git a/pom.xml b/pom.xml index b4bc4ebf..2532263e 100644 --- a/pom.xml +++ b/pom.xml @@ -895,6 +895,13 @@ 2.0.1.Final true + + + org.hibernate.validator + hibernate-validator + 6.1.7.Final + test + org.projectlombok diff --git a/src/main/java/org/springframework/hateoas/AffordanceModel.java b/src/main/java/org/springframework/hateoas/AffordanceModel.java index 370b8920..897c1b02 100644 --- a/src/main/java/org/springframework/hateoas/AffordanceModel.java +++ b/src/main/java/org/springframework/hateoas/AffordanceModel.java @@ -137,6 +137,22 @@ public abstract class AffordanceModel { return this.output; } + /** + * Creates a {@link List} of properties based on the given creator. + * + * @param the property type + * @param creator a creator function that turns an {@link InputPayloadMetadata} and {@link PropertyMetadata} into a + * property instance. + * @return will never be {@literal null}. + * @since 1.3 + */ + public List createProperties(BiFunction creator) { + + return input.stream() + .map(it -> creator.apply(input, it)) + .collect(Collectors.toList()); + } + /* * (non-Javadoc) * @see java.lang.Object#equals(java.lang.Object) @@ -208,39 +224,15 @@ public abstract class AffordanceModel { : DelegatingInputPayloadMetadata.of(metadata); } - /** - * Creates a {@link List} of properties based on the given creator and customizer. The {@link PropertyMetadata} will - * be applied to the instance returned from the creator before its handed to the customizer. - * - * @param the property type - * @param creator a creator function that turns a {@link PropertyMetadata} into a property instance. - * @param customizer a {@link BiFunction} to apply after the {@link PropertyMetadata} has been applied to the - * property instance. - * @return will never be {@literal null}. - */ - default & Named> List createProperties( - Function creator, - BiFunction customizer) { - - Assert.notNull(creator, "Creator must not be null!"); - Assert.notNull(customizer, "Customizer must not be null!"); - - return stream().map(creator).map(it -> { - - return getPropertyMetadata(it.getName()) - .map(metadata -> customizer.apply(it.apply(metadata), metadata)) - .orElse(it); - - }).collect(Collectors.toList()); - } - /** * Applies the {@link InputPayloadMetadata} to the given target. * * @param * @param target * @return + * @deprecated since 1.3, prefer setting up the model types via {@link #createProperties(Function)} */ + @Deprecated default & Named> T applyTo(T target) { return getPropertyMetadata(target.getName()) // @@ -396,6 +388,50 @@ public abstract class AffordanceModel { * @return */ ResolvableType getType(); + + /** + * Return the minimum value allowed for a numeric type. + * + * @return can be {@literal null}. + * @since 1.3 + */ + @Nullable + default Long getMin() { + return null; + } + + /** + * Return the maximum value allowed for a numeric type. + * + * @return can be {@literal null}. + * @since 1.3 + */ + @Nullable + default Long getMax() { + return null; + } + + /** + * Return the minimum length allowed for a string type. + * + * @return can be {@literal null}. + * @since 1.3 + */ + @Nullable + default Long getMinLength() { + return null; + } + + /** + * Return the maximum length allowed for a string type. + * + * @return can be {@literal null}. + * @since 1.3 + */ + @Nullable + default Long getMaxLength() { + return null; + } } /** diff --git a/src/main/java/org/springframework/hateoas/mediatype/PropertyUtils.java b/src/main/java/org/springframework/hateoas/mediatype/PropertyUtils.java index cc392efe..ecfe90ea 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/PropertyUtils.java +++ b/src/main/java/org/springframework/hateoas/mediatype/PropertyUtils.java @@ -20,21 +20,13 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; @@ -56,7 +48,6 @@ import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ReflectionUtils; -import org.springframework.util.StringUtils; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -512,6 +503,14 @@ public class PropertyUtils { */ private static class Jsr303AwarePropertyMetadata extends DefaultPropertyMetadata { + private static final Optional> LENGTH_ANNOTATION; + + static { + + LENGTH_ANNOTATION = Optional.ofNullable(org.springframework.hateoas.support.ClassUtils + .loadIfPresent("org.hibernate.validator.constraints.Length")); + } + private final AnnotatedProperty property; /** @@ -541,24 +540,65 @@ public class PropertyUtils { */ @Override public Optional getPattern() { - - MergedAnnotation annotation = property.getAnnotation(Pattern.class); - - if (annotation.isPresent()) { - return fromAnnotation(annotation); - } - - annotation = property.getTypeAnnotations().get(Pattern.class); - - return annotation.isPresent() // - ? fromAnnotation(annotation) // - : Optional.empty(); + return getAnnotationAttribute(Pattern.class, "regexp", String.class); } - private static Optional fromAnnotation(MergedAnnotation annotation) { + /* + * (non-Javadoc) + * @see org.springframework.hateoas.AffordanceModel.PropertyMetadata#getMin() + */ + @Nullable + @Override + public Long getMin() { + return getAnnotationAttribute(Min.class, "value", Long.class).orElse(null); + } - return Optional.of(annotation.getString("regexp")) // - .filter(StringUtils::hasText); + /* + * (non-Javadoc) + * @see org.springframework.hateoas.AffordanceModel.PropertyMetadata#getMax() + */ + @Nullable + @Override + public Long getMax() { + return getAnnotationAttribute(Max.class, "value", Long.class).orElse(null); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.AffordanceModel.PropertyMetadata#getMinLength() + */ + @Nullable + @Override + public Long getMinLength() { + return LENGTH_ANNOTATION.flatMap(it -> getAnnotationAttribute(it, "min", Integer.class)) + .map(Integer::longValue) + .orElse(null); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.AffordanceModel.PropertyMetadata#getMaxLength() + */ + @Nullable + @Override + public Long getMaxLength() { + return LENGTH_ANNOTATION.flatMap(it -> getAnnotationAttribute(it, "max", Integer.class)) + .map(Integer::longValue) + .orElse(null); + } + + private Optional getAnnotationAttribute(Class annotation, String attribute, + Class type) { + + MergedAnnotation mergedAnnotation = property.getAnnotation(annotation); + + if (mergedAnnotation.isPresent()) { + return mergedAnnotation.getValue(attribute, type); + } + + mergedAnnotation = property.getTypeAnnotations().get(annotation); + + return mergedAnnotation.isPresent() ? mergedAnnotation.getValue(attribute, type) : Optional.empty(); } } } diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModel.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModel.java index 221e45e7..ad889664 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModel.java +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModel.java @@ -15,27 +15,13 @@ */ package org.springframework.hateoas.mediatype.hal.forms; -import static org.springframework.http.HttpMethod.*; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.EnumSet; import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.function.BiFunction; -import java.util.function.Function; -import org.springframework.context.MessageSourceResolvable; import org.springframework.hateoas.AffordanceModel; import org.springframework.hateoas.Link; import org.springframework.hateoas.QueryParameter; -import org.springframework.hateoas.mediatype.MessageResolver; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; -import org.springframework.lang.NonNull; -import org.springframework.lang.Nullable; -import org.springframework.util.StringUtils; /** * {@link AffordanceModel} for a HAL-FORMS {@link MediaType}. @@ -45,121 +31,8 @@ import org.springframework.util.StringUtils; */ class HalFormsAffordanceModel extends AffordanceModel { - private static final Set ENTITY_ALTERING_METHODS = EnumSet.of(POST, PUT, PATCH); - public HalFormsAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType, List queryMethodParameters, PayloadMetadata outputType) { super(name, link, httpMethod, inputType, queryMethodParameters, outputType); } - - /** - * Applies the given customizer to all {@link HalFormsProperty} of this model. - * - * @param customizer must not be {@literal null}. - * @return - */ - public List getProperties(HalFormsConfiguration configuration, MessageResolver resolver) { - - if (!ENTITY_ALTERING_METHODS.contains(getHttpMethod())) { - return Collections.emptyList(); - } - - Function creator = it -> { - - HalFormsProperty property = new HalFormsProperty().withName(it.getName()); - - return configuration.getTypePatternFor(it.getType()) // - .map(property::withRegex) // - .orElse(property); - }; - - return getInput().createProperties(creator, (property, metadata) -> { - - return Optional.of(property) - .map(it -> apply(it, I18nedPlaceholder::of, it::withPlaceholder, resolver)) - .map(it -> apply(it, I18nedPropertyPrompt::of, it::withPrompt, resolver)) - .map(it -> hasHttpMethod(HttpMethod.PATCH) ? it.withRequired(false) : it) - .orElse(property); - }); - } - - private HalFormsProperty apply(HalFormsProperty property, - BiFunction creator, - Function application, MessageResolver resolver) { - - InputPayloadMetadata metadata = getInput(); - I18nedPropertyMetadata source = creator.apply(metadata, property); - String resolved = resolver.resolve(source); - - return !StringUtils.hasText(resolved) - ? property - : application.apply(resolved); - } - - private static class I18nedPropertyMetadata implements MessageSourceResolvable { - - private final String template; - private final InputPayloadMetadata metadata; - private final HalFormsProperty property; - - protected I18nedPropertyMetadata(String template, InputPayloadMetadata metadata, HalFormsProperty property) { - - this.template = template; - this.metadata = metadata; - this.property = property; - } - - /* - * (non-Javadoc) - * @see org.springframework.context.MessageSourceResolvable#getDefaultMessage() - */ - @Nullable - @Override - public String getDefaultMessage() { - return ""; - } - - /* - * (non-Javadoc) - * @see org.springframework.context.MessageSourceResolvable#getCodes() - */ - @NonNull - @Override - public String[] getCodes() { - - String globalCode = String.format(template, property.getName()); - - List codes = new ArrayList<>(); - - metadata.getI18nCodes().stream() // - .map(it -> String.format("%s.%s", it, globalCode)) // - .forEach(codes::add); - - codes.add(globalCode); - - return codes.toArray(new String[0]); - } - } - - private static class I18nedPropertyPrompt extends I18nedPropertyMetadata { - - private I18nedPropertyPrompt(InputPayloadMetadata metadata, HalFormsProperty property) { - super("%s._prompt", metadata, property); - } - - public static I18nedPropertyPrompt of(InputPayloadMetadata metadata, HalFormsProperty property) { - return new I18nedPropertyPrompt(metadata, property); - } - } - - private static class I18nedPlaceholder extends I18nedPropertyMetadata { - - private I18nedPlaceholder(InputPayloadMetadata metadata, HalFormsProperty property) { - super("%s._placeholder", metadata, property); - } - - public static I18nedPlaceholder of(InputPayloadMetadata metadata, HalFormsProperty property) { - return new I18nedPlaceholder(metadata, property); - } - } } diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsProperty.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsProperty.java index 66502009..4b3d67c5 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsProperty.java +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsProperty.java @@ -16,10 +16,9 @@ package org.springframework.hateoas.mediatype.hal.forms; import java.util.Objects; +import java.util.Optional; import org.springframework.hateoas.AffordanceModel.Named; -import org.springframework.hateoas.AffordanceModel.PropertyMetadata; -import org.springframework.hateoas.AffordanceModel.PropertyMetadataConfigured; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -36,11 +35,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; * @see https://mamund.site44.com/misc/hal-forms/ */ @JsonInclude(Include.NON_DEFAULT) -final class HalFormsProperty implements PropertyMetadataConfigured, Named { +final class HalFormsProperty implements Named { private final String name, value, prompt, regex, placeholder; private final boolean templated, multi; private final @JsonInclude(Include.NON_DEFAULT) boolean readOnly, required; + private final @Nullable Long min, max, minLength, maxLength; HalFormsProperty() { @@ -53,10 +53,15 @@ final class HalFormsProperty implements PropertyMetadataConfigured regex) { + return regex.map(it -> withRegex(it)).orElse(this); } /** @@ -172,7 +177,7 @@ final class HalFormsProperty implements PropertyMetadataConfigured ENTITY_ALTERING_METHODS = EnumSet.of(POST, PUT, PATCH); + + private final HalFormsConfiguration configuration; + private final MessageResolver resolver; + + /** + * Creates a new {@link HalFormsPropertyFactory} for the given {@link HalFormsConfiguration} and + * {@link MessageResolver}. + * + * @param configuration must not be {@literal null}. + * @param resolver must not be {@literal null}. + */ + public HalFormsPropertyFactory(HalFormsConfiguration configuration, MessageResolver resolver) { + + Assert.notNull(configuration, "HalFormsConfiguration must not be null!"); + Assert.notNull(resolver, "MessageResolver must not be null!"); + + this.configuration = configuration; + this.resolver = resolver; + } + + /** + * Creates {@link HalFormsProperty} from the given {@link HalFormsAffordanceModel}. + * + * @param model must not be {@literal null}. + * @return + */ + public List createProperties(HalFormsAffordanceModel model) { + + Assert.notNull(model, "HalFormsModel must not be null!"); + + if (!ENTITY_ALTERING_METHODS.contains(model.getHttpMethod())) { + return Collections.emptyList(); + } + + return model.createProperties((payload, metadata) -> { + + HalFormsProperty property = new HalFormsProperty() + .withName(metadata.getName()) + .withRequired(metadata.isRequired()) // + .withReadOnly(metadata.isReadOnly()) + .withMin(metadata.getMin()) + .withMax(metadata.getMax()) + .withMinLength(metadata.getMinLength()) + .withMaxLength(metadata.getMaxLength()) + .withRegex(lookupRegex(metadata)); + + Function factory = I18nedPropertyMetadata.factory(payload, property); + + return Optional.of(property) + .map(it -> i18n(it, factory.apply("_placeholder"), it::withPlaceholder)) + .map(it -> i18n(it, factory.apply("_prompt"), it::withPrompt)) + .map(it -> model.hasHttpMethod(HttpMethod.PATCH) ? it.withRequired(false) : it) + .orElse(property); + }); + } + + private Optional lookupRegex(PropertyMetadata metadata) { + + Optional pattern = metadata.getPattern(); + + if (pattern.isPresent()) { + return pattern; + } + + return configuration.getTypePatternFor(metadata.getType()); + } + + private HalFormsProperty i18n(HalFormsProperty property, MessageSourceResolvable metadata, + Function application) { + + String resolved = resolver.resolve(metadata); + + return !StringUtils.hasText(resolved) + ? property + : application.apply(resolved); + } + + private static class I18nedPropertyMetadata implements MessageSourceResolvable { + + private final String template; + private final InputPayloadMetadata metadata; + private final HalFormsProperty property; + + private I18nedPropertyMetadata(String template, InputPayloadMetadata metadata, HalFormsProperty property) { + + this.template = template; + this.metadata = metadata; + this.property = property; + } + + public static Function factory(InputPayloadMetadata metadata, + HalFormsProperty property) { + return suffix -> new I18nedPropertyMetadata("%s.".concat(suffix), metadata, property); + } + + /* + * (non-Javadoc) + * @see org.springframework.context.MessageSourceResolvable#getDefaultMessage() + */ + @Nullable + @Override + public String getDefaultMessage() { + return ""; + } + + /* + * (non-Javadoc) + * @see org.springframework.context.MessageSourceResolvable#getCodes() + */ + @NonNull + @Override + public String[] getCodes() { + + String globalCode = String.format(template, property.getName()); + + List codes = new ArrayList<>(); + + metadata.getI18nCodes().stream() // + .map(it -> String.format("%s.%s", it, globalCode)) // + .forEach(codes::add); + + codes.add(globalCode); + + return codes.toArray(new String[0]); + } + } +} diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilder.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilder.java index f96c3307..56b708ff 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilder.java +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilder.java @@ -37,13 +37,13 @@ import org.springframework.util.Assert; class HalFormsTemplateBuilder { - private final HalFormsConfiguration configuration; private final MessageResolver resolver; + private final HalFormsPropertyFactory factory; public HalFormsTemplateBuilder(HalFormsConfiguration configuration, MessageResolver resolver) { - this.configuration = configuration; this.resolver = resolver; + this.factory = new HalFormsPropertyFactory(configuration, resolver); } /** @@ -73,7 +73,7 @@ class HalFormsTemplateBuilder { .forEach(it -> { HalFormsTemplate template = HalFormsTemplate.forMethod(it.getHttpMethod()) // - .withProperties(it.getProperties(configuration, resolver)); + .withProperties(factory.createProperties(it)); template = applyTo(template, TemplateTitle.of(it, templates.isEmpty())); templates.put(templates.isEmpty() ? "default" : it.getName(), template); diff --git a/src/main/java/org/springframework/hateoas/support/ClassUtils.java b/src/main/java/org/springframework/hateoas/support/ClassUtils.java new file mode 100644 index 00000000..0a3caa73 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/support/ClassUtils.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021 the original author or authors. + * + * 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 + * + * https://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.springframework.hateoas.support; + +import org.springframework.lang.Nullable; + +/** + * @author Oliver Drotbohm + */ +public class ClassUtils { + + @Nullable + @SuppressWarnings("unchecked") + public static Class loadIfPresent(String type) { + + try { + return (Class) org.springframework.util.ClassUtils.forName(type, + org.springframework.hateoas.support.ClassUtils.class.getClassLoader()); + } catch (ClassNotFoundException | LinkageError e) { + return null; + } + } +} diff --git a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilderUnitTest.java b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilderUnitTest.java index 81ba7ad6..da32aea7 100644 --- a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilderUnitTest.java +++ b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilderUnitTest.java @@ -20,10 +20,14 @@ import static org.assertj.core.api.Assertions.*; import lombok.Getter; import java.util.Map; +import java.util.Optional; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; +import org.hibernate.validator.constraints.Length; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -66,10 +70,6 @@ class HalFormsTemplateBuilderUnitTest { @Test void allPropertiesAreOptionalForPatchRequests() throws Exception { - Affordances.of(Link.of("/example")) // - .afford(HttpMethod.PATCH) // - .withInput(RequiredProperty.class); - RequiredProperty model = new RequiredProperty(); model.add(Affordances.of(Link.of("/example")) // .afford(HttpMethod.PATCH) // @@ -95,6 +95,26 @@ class HalFormsTemplateBuilderUnitTest { assertThat(template.getPropertyByName("name").map(HalFormsProperty::isRequired)).hasValue(true); } + @Test // #1439 + void considersMinandMaxAnnotations() { + + Link link = Affordances.of(Link.of("/example")) // + .afford(HttpMethod.POST) // + .withInput(Payload.class) // + .toLink(); + + HalFormsTemplate template = new HalFormsTemplateBuilder(new HalFormsConfiguration(), + MessageResolver.DEFAULTS_ONLY).findTemplates(new RepresentationModel<>().add(link)).get("default"); + + Optional name = template.getPropertyByName("number"); + assertThat(name).map(HalFormsProperty::getMin).hasValue(2L); + assertThat(name).map(HalFormsProperty::getMax).hasValue(5L); + + Optional text = template.getPropertyByName("text"); + assertThat(text).map(HalFormsProperty::getMinLength).hasValue(2L); + assertThat(text).map(HalFormsProperty::getMaxLength).hasValue(5L); + } + @Getter static class PatternExample extends RepresentationModel { @@ -114,4 +134,15 @@ class HalFormsTemplateBuilderUnitTest { static class RequiredProperty extends RepresentationModel { @NotNull String name; } + + @Getter + static class Payload { + + @Min(2) // + @Max(5) // + Integer number; + + @Length(min = 2, max = 5) // + String text; + } }