Skip to content

Commit

Permalink
#1439 - Support for HAL FORMS min, max, minLength and maxLength attri…
Browse files Browse the repository at this point in the history
…butes.

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(…).
odrotbohm committed Jan 20, 2021
1 parent a16c54f commit 675f9e3
Showing 9 changed files with 511 additions and 212 deletions.
7 changes: 7 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
@@ -895,6 +895,13 @@
<version>2.0.1.Final</version>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.1.7.Final</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
88 changes: 62 additions & 26 deletions src/main/java/org/springframework/hateoas/AffordanceModel.java
Original file line number Diff line number Diff line change
@@ -137,6 +137,22 @@ public PayloadMetadata getOutput() {
return this.output;
}

/**
* Creates a {@link List} of properties based on the given creator.
*
* @param <T> 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 <T> List<T> createProperties(BiFunction<InputPayloadMetadata, PropertyMetadata, T> 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 @@ static InputPayloadMetadata from(PayloadMetadata metadata) {
: 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 <T> 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 <T extends PropertyMetadataConfigured<T> & Named> List<T> createProperties(
Function<PropertyMetadata, T> creator,
BiFunction<T, PropertyMetadata, T> 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 <T>
* @param target
* @return
* @deprecated since 1.3, prefer setting up the model types via {@link #createProperties(Function)}
*/
@Deprecated
default <T extends PropertyMetadataConfigured<T> & Named> T applyTo(T target) {

return getPropertyMetadata(target.getName()) //
@@ -396,6 +388,50 @@ default boolean hasName(String name) {
* @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;
}
}

/**
Original file line number Diff line number Diff line change
@@ -20,21 +20,13 @@
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.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 int compareTo(DefaultPropertyMetadata that) {
*/
private static class Jsr303AwarePropertyMetadata extends DefaultPropertyMetadata {

private static final Optional<Class<? extends Annotation>> 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 boolean isRequired() {
*/
@Override
public Optional<String> getPattern() {
return getAnnotationAttribute(Pattern.class, "regexp", String.class);
}

MergedAnnotation<Pattern> annotation = property.getAnnotation(Pattern.class);
/*
* (non-Javadoc)
* @see org.springframework.hateoas.AffordanceModel.PropertyMetadata#getMin()
*/
@Nullable
@Override
public Long getMin() {
return getAnnotationAttribute(Min.class, "value", Long.class).orElse(null);
}

if (annotation.isPresent()) {
return fromAnnotation(annotation);
}
/*
* (non-Javadoc)
* @see org.springframework.hateoas.AffordanceModel.PropertyMetadata#getMax()
*/
@Nullable
@Override
public Long getMax() {
return getAnnotationAttribute(Max.class, "value", Long.class).orElse(null);
}

annotation = property.getTypeAnnotations().get(Pattern.class);
/*
* (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);
}

return annotation.isPresent() //
? fromAnnotation(annotation) //
: Optional.empty();
/*
* (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 static Optional<String> fromAnnotation(MergedAnnotation<Pattern> annotation) {
private <T> Optional<T> getAnnotationAttribute(Class<? extends Annotation> annotation, String attribute,
Class<T> type) {

MergedAnnotation<? extends Annotation> mergedAnnotation = property.getAnnotation(annotation);

if (mergedAnnotation.isPresent()) {
return mergedAnnotation.getValue(attribute, type);
}

mergedAnnotation = property.getTypeAnnotations().get(annotation);

return Optional.of(annotation.getString("regexp")) //
.filter(StringUtils::hasText);
return mergedAnnotation.isPresent() ? mergedAnnotation.getValue(attribute, type) : Optional.empty();
}
}
}
Original file line number Diff line number Diff line change
@@ -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 @@
*/
class HalFormsAffordanceModel extends AffordanceModel {

private static final Set<HttpMethod> ENTITY_ALTERING_METHODS = EnumSet.of(POST, PUT, PATCH);

public HalFormsAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType,
List<QueryParameter> 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<HalFormsProperty> getProperties(HalFormsConfiguration configuration, MessageResolver resolver) {

if (!ENTITY_ALTERING_METHODS.contains(getHttpMethod())) {
return Collections.emptyList();
}

Function<PropertyMetadata, HalFormsProperty> 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<InputPayloadMetadata, HalFormsProperty, I18nedPropertyMetadata> creator,
Function<String, HalFormsProperty> 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<String> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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 @@
* @see https://mamund.site44.com/misc/hal-forms/
*/
@JsonInclude(Include.NON_DEFAULT)
final class HalFormsProperty implements PropertyMetadataConfigured<HalFormsProperty>, 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<HalFormsPrope
this.required = false;
this.multi = false;
this.placeholder = null;
this.min = null;
this.max = null;
this.minLength = null;
this.maxLength = null;
}

private HalFormsProperty(String name, boolean readOnly, String value, String prompt, String regex, boolean templated,
boolean required, boolean multi, String placeholder) {
boolean required, boolean multi, String placeholder, @Nullable Long min, @Nullable Long max,
@Nullable Long minLength, @Nullable Long maxLength) {

Assert.notNull(name, "name must not be null!");

@@ -69,6 +74,10 @@ private HalFormsProperty(String name, boolean readOnly, String value, String pro
this.required = required;
this.multi = multi;
this.placeholder = StringUtils.hasText(placeholder) ? placeholder : null;
this.min = min;
this.max = max;
this.minLength = minLength;
this.maxLength = maxLength;
}

/**
@@ -81,20 +90,6 @@ static HalFormsProperty named(String name) {
return new HalFormsProperty().withName(name);
}

/*
* (non-Javadoc)
* @see org.springframework.hateoas.mediatype.PropertyMetadataAware#apply(org.springframework.hateoas.mediatype.PropertyUtils.PropertyMetadata)
*/
public HalFormsProperty apply(PropertyMetadata metadata) {

HalFormsProperty customized = withRequired(metadata.isRequired()) //
.withReadOnly(metadata.isReadOnly());

return metadata.getPattern() //
.map(customized::withRegex) //
.orElse(customized);
}

/**
* Create a new {@link HalFormsProperty} by copying attributes and replacing the {@literal name}.
*
@@ -107,7 +102,7 @@ HalFormsProperty withName(String name) {

return this.name == name ? this
: new HalFormsProperty(name, this.readOnly, this.value, this.prompt, this.regex, this.templated, this.required,
this.multi, this.placeholder);
this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength);
}

/**
@@ -120,7 +115,7 @@ HalFormsProperty withReadOnly(boolean readOnly) {

return this.readOnly == readOnly ? this
: new HalFormsProperty(this.name, readOnly, this.value, this.prompt, this.regex, this.templated, this.required,
this.multi, this.placeholder);
this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength);
}

/**
@@ -133,7 +128,7 @@ HalFormsProperty withValue(String value) {

return this.value == value ? this
: new HalFormsProperty(this.name, this.readOnly, value, this.prompt, this.regex, this.templated, this.required,
this.multi, this.placeholder);
this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength);
}

/**
@@ -146,7 +141,7 @@ HalFormsProperty withPrompt(String prompt) {

return this.prompt == prompt ? this
: new HalFormsProperty(this.name, this.readOnly, this.value, prompt, this.regex, this.templated, this.required,
this.multi, this.placeholder);
this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength);
}

/**
@@ -159,7 +154,17 @@ HalFormsProperty withRegex(String regex) {

return this.regex == regex ? this
: new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, regex, this.templated, this.required,
this.multi, this.placeholder);
this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength);
}

/**
* Create a new {@link HalFormsProperty} by copying attributes and replacing the {@literal regex}.
*
* @param regex
* @return
*/
HalFormsProperty withRegex(Optional<String> regex) {
return regex.map(it -> withRegex(it)).orElse(this);
}

/**
@@ -172,7 +177,7 @@ HalFormsProperty withTemplated(boolean templated) {

return this.templated == templated ? this
: new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, templated, this.required,
this.multi, this.placeholder);
this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength);
}

/**
@@ -185,7 +190,7 @@ HalFormsProperty withRequired(boolean required) {

return this.required == required ? this
: new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated, required,
this.multi, this.placeholder);
this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength);
}

/**
@@ -198,7 +203,7 @@ HalFormsProperty withMulti(boolean multi) {

return this.multi == multi ? this
: new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated,
this.required, multi, this.placeholder);
this.required, multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength);
}

/**
@@ -211,9 +216,65 @@ HalFormsProperty withPlaceholder(String placeholder) {

return this.placeholder == placeholder ? this
: new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated,
this.required, this.multi, placeholder);
this.required, this.multi, placeholder, this.min, this.max, this.minLength, this.maxLength);
}

/**
* Create a new {@link HalFormsProperty} by copying attributes and replacing {@literal min}.
*
* @param min
* @return
*/
HalFormsProperty withMin(@Nullable Long min) {

return Objects.equals(this.min, min) ? this
: new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated,
this.required, this.multi, this.placeholder, min, this.max, this.minLength, this.maxLength);
}

/**
* Create a new {@link HalFormsProperty} by copying attributes and replacing {@literal max}.
*
* @param max
* @return
*/
HalFormsProperty withMax(@Nullable Long max) {

return Objects.equals(this.max, max) ? this
: new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated,
this.required, this.multi, this.placeholder, this.min, max, this.minLength, this.maxLength);
}

/**
* Create a new {@link HalFormsProperty} by copying attributes and replacing {@literal minLength}.
*
* @param minLength
* @return
*/
HalFormsProperty withMinLength(@Nullable Long minLength) {

return Objects.equals(this.minLength, minLength) ? this
: new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated,
this.required, this.multi, this.placeholder, this.min, this.max, minLength, this.maxLength);
}

/**
* Create a new {@link HalFormsProperty} by copying attributes and replacing {@literal maxLength}.
*
* @param maxLength
* @return
*/
HalFormsProperty withMaxLength(@Nullable Long maxLength) {

return Objects.equals(this.maxLength, maxLength) ? this
: new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated,
this.required, this.multi, this.placeholder, this.min, this.max, this.minLength, maxLength);
}

/*
* (non-Javadoc)
* @see org.springframework.hateoas.AffordanceModel.Named#getName()
*/
@JsonProperty
public String getName() {
return this.name;
@@ -264,6 +325,42 @@ public String getPlaceholder() {
return this.placeholder;
}

/**
* @return the min
*/
@Nullable
@JsonProperty
public Long getMin() {
return min;
}

/**
* @return the max
*/
@Nullable
@JsonProperty
public Long getMax() {
return max;
}

/**
* @return the minLength
*/
@Nullable
@JsonProperty
public Long getMinLength() {
return minLength;
}

/**
* @return the maxLength
*/
@Nullable
@JsonProperty
public Long getMaxLength() {
return maxLength;
}

/*
* (non-Javadoc)
* @see java.lang.Object#equals(java.lang.Object)
@@ -298,7 +395,7 @@ public boolean equals(@Nullable Object o) {
public int hashCode() {

return Objects.hash(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated, this.required,
this.multi, this.placeholder);
this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength);
}

/*
@@ -316,6 +413,11 @@ public String toString() {
+ ", templated=" + this.templated //
+ ", required=" + this.required //
+ ", multi=" + this.multi //
+ ", placeholder=" + this.placeholder + ")";
+ ", placeholder=" + this.placeholder //
+ ", min=" + this.min //
+ ", max=" + this.max //
+ ", minLength=" + this.minLength //
+ ", maxLength=" + this.maxLength //
+ ")";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/*
* 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.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.Function;

import org.springframework.context.MessageSourceResolvable;
import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
import org.springframework.hateoas.AffordanceModel.PropertyMetadata;
import org.springframework.hateoas.mediatype.MessageResolver;
import org.springframework.http.HttpMethod;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

/**
* Factory to create {@link HalFormsProperty} instances.
*
* @author Oliver Drotbohm
* @since 1.3
* @soundtrack The Chicks - March March (Gaslighter)
*/
public class HalFormsPropertyFactory {

private static final Set<HttpMethod> 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<HalFormsProperty> 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<String, I18nedPropertyMetadata> 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<String> lookupRegex(PropertyMetadata metadata) {

Optional<String> pattern = metadata.getPattern();

if (pattern.isPresent()) {
return pattern;
}

return configuration.getTypePatternFor(metadata.getType());
}

private HalFormsProperty i18n(HalFormsProperty property, MessageSourceResolvable metadata,
Function<String, HalFormsProperty> 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<String, I18nedPropertyMetadata> 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<String> 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]);
}
}
}
Original file line number Diff line number Diff line change
@@ -37,13 +37,13 @@

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 @@ public Map<String, HalFormsTemplate> findTemplates(RepresentationModel<?> resour
.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);
36 changes: 36 additions & 0 deletions src/main/java/org/springframework/hateoas/support/ClassUtils.java
Original file line number Diff line number Diff line change
@@ -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 <T> Class<T> loadIfPresent(String type) {

try {
return (Class<T>) org.springframework.util.ClassUtils.forName(type,
org.springframework.hateoas.support.ClassUtils.class.getClassLoader());
} catch (ClassNotFoundException | LinkageError e) {
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -20,10 +20,14 @@
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 @@ void detectsRegularExpressionsOnProperties(String propertyName, String expected)
@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 @@ void allPropertiesAreOptionalForPatchRequests() throws Exception {
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<HalFormsProperty> name = template.getPropertyByName("number");
assertThat(name).map(HalFormsProperty::getMin).hasValue(2L);
assertThat(name).map(HalFormsProperty::getMax).hasValue(5L);

Optional<HalFormsProperty> text = template.getPropertyByName("text");
assertThat(text).map(HalFormsProperty::getMinLength).hasValue(2L);
assertThat(text).map(HalFormsProperty::getMaxLength).hasValue(5L);
}

@Getter
static class PatternExample extends RepresentationModel<PatternExample> {

@@ -114,4 +134,15 @@ static class WithTypeLevelAnnotation {}
static class RequiredProperty extends RepresentationModel<RequiredProperty> {
@NotNull String name;
}

@Getter
static class Payload {

@Min(2) //
@Max(5) //
Integer number;

@Length(min = 2, max = 5) //
String text;
}
}

0 comments on commit 675f9e3

Please sign in to comment.