diff --git a/pom.xml b/pom.xml index ba33e308f..ac9da1bc3 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.hateoas spring-hateoas - 1.1.0.BUILD-SNAPSHOT + 1.1.0.HATEOAS-1114-SNAPSHOT Spring HATEOAS https://github.com/spring-projects/spring-hateoas diff --git a/src/main/asciidoc/mediatypes.adoc b/src/main/asciidoc/mediatypes.adoc index e99077198..417f83c7f 100644 --- a/src/main/asciidoc/mediatypes.adoc +++ b/src/main/asciidoc/mediatypes.adoc @@ -222,6 +222,8 @@ Spring HATEOAS allows to customize those by shaping the model type for the input For types that you cannot annotate manually, you can register a custom pattern via a `HalFormsConfiguration` bean present in the application context. +.Registering regex patterns for types +==== [source, java] ---- @Configuration @@ -235,9 +237,35 @@ class CustomConfiguration { } } ---- +==== This setup will cause the HAL-FORMS template properties for representation model properties of type `CreditCardNumber` to declare a `regex` field with value `[0-9]{16}`. +[[mediatypes.hal-forms.property-order]] +=== Ordering template properties + +By default, HAL-FORMS properties are gleaned from the related domain object using Spring Framework's `BeanUtils`. There is no defined ordering. If you wish to enforce a +particular order on these properties, you can specify that through `HalFormsConfiguration`. + +.Registering order of template properties for types +==== +[source,java] +---- +@Configuration +class CustomConfiguration { + + @Bean + HalFormsConfiguration halFormsConfiguration() { + + HalFormsConfiguration configuration = new HalFormsConfiguration(); + configuration.withFieldOrderFor(Employee.class, "employeeId", "name", "role"); + } +} +---- +==== + +The listed properties will be rendered first, followed by any not cited. + [[mediatypes.hal-forms.i18n]] === Internationalization of form attributes HAL-FORMS contains attributes that are intended for human interpretation, like a template's title or property prompts. diff --git a/src/main/java/org/springframework/hateoas/AffordanceModel.java b/src/main/java/org/springframework/hateoas/AffordanceModel.java index 476405379..46b762c48 100644 --- a/src/main/java/org/springframework/hateoas/AffordanceModel.java +++ b/src/main/java/org/springframework/hateoas/AffordanceModel.java @@ -161,6 +161,8 @@ static InputPayloadMetadata from(PayloadMetadata metadata) { * @return */ List getI18nCodes(); + + Optional getType(); } /** @@ -210,7 +212,17 @@ public T customize(T target, Function cus public List getI18nCodes() { return Collections.emptyList(); } - } + + @Override + public Optional getType() { + + if (metadata instanceof InputPayloadMetadata) { + ((InputPayloadMetadata) metadata).getType(); + } + + return Optional.empty(); + } + } /** * Metadata about the property model of a representation. diff --git a/src/main/java/org/springframework/hateoas/mediatype/Affordances.java b/src/main/java/org/springframework/hateoas/mediatype/Affordances.java index 529417468..6d7c5a725 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/Affordances.java +++ b/src/main/java/org/springframework/hateoas/mediatype/Affordances.java @@ -308,17 +308,10 @@ private String getNameOrDefault() { String name = method.toString().toLowerCase(); - ResolvableType type = TypeBasedPayloadMetadata.class.isInstance(inputMetdata) // - ? TypeBasedPayloadMetadata.class.cast(inputMetdata).getType() // - : null; - - if (type == null) { - return name; - } - - Class resolvedType = type.resolve(); - - return resolvedType == null ? name : name.concat(resolvedType.getSimpleName()); + return inputMetdata.getType() // + .map(ResolvableType::resolve) // + .map(resolvedType -> resolvedType == null ? name : name.concat(resolvedType.getSimpleName())) // + .orElse(name); } } } diff --git a/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java b/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java index 2b7cee5d2..3fe442fff 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java +++ b/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java @@ -20,6 +20,7 @@ import java.util.Arrays; import java.util.List; +import java.util.Optional; import java.util.SortedMap; import java.util.TreeMap; import java.util.function.Function; @@ -39,7 +40,7 @@ */ class TypeBasedPayloadMetadata implements InputPayloadMetadata { - private final @Getter(AccessLevel.PACKAGE) ResolvableType type; + private final ResolvableType type; private final SortedMap properties; TypeBasedPayloadMetadata(ResolvableType type, Stream properties) { @@ -93,4 +94,9 @@ public List getI18nCodes() { return Arrays.asList(type.getName(), type.getSimpleName()); } + + @Override + public Optional getType() { + return Optional.ofNullable(this.type); + } } diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsConfiguration.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsConfiguration.java index 6d94c5537..cdc312db8 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsConfiguration.java +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsConfiguration.java @@ -18,7 +18,9 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; +import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -36,6 +38,7 @@ public class HalFormsConfiguration { private final @Getter HalConfiguration halConfiguration; private final Map, String> patterns = new HashMap<>(); + private final Map, List> fieldOrder = new HashMap<>(); /** * Creates a new {@link HalFormsConfiguration} backed by a default {@link HalConfiguration}. @@ -60,4 +63,15 @@ public HalFormsConfiguration registerPattern(Class type, String pattern) { Optional getTypePatternFor(ResolvableType type) { return Optional.ofNullable(patterns.get(type.resolve(Object.class))); } + + public HalFormsConfiguration withFieldOrderFor(Class type, String... fieldNames) { + + this.fieldOrder.put(type, Arrays.asList(fieldNames)); + + return this; + } + + Optional> getFieldOrderFor(ResolvableType type) { + return Optional.ofNullable(fieldOrder.get(type.resolve(Object.class))); + } } 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 41541018f..834150d0f 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 @@ -79,6 +79,8 @@ public Map findTemplates(RepresentationModel resour .map(property -> it.hasHttpMethod(HttpMethod.PATCH) ? property.withRequired(false) : property) .collect(Collectors.toList()); + propertiesWithPrompt = sorted(propertiesWithPrompt, it.getInput()); + HalFormsTemplate template = HalFormsTemplate.forMethod(it.getHttpMethod()) // .withProperties(propertiesWithPrompt); @@ -89,6 +91,33 @@ public Map findTemplates(RepresentationModel resour return templates; } + private List sorted(List properties, InputPayloadMetadata input) { + + return input.getType() // + .flatMap(configuration::getFieldOrderFor) // + .map(fieldsToSortBy -> { + + List propertiesToSort = new ArrayList<>(properties); + List sortedProperties = new ArrayList<>(); + + for (String propertyName : fieldsToSortBy) { + properties.stream() // + .filter(halFormsProperty -> halFormsProperty.getName().equals(propertyName)) // + .findFirst() // + .ifPresent(halFormsProperty -> { + sortedProperties.add(halFormsProperty); + propertiesToSort.remove(halFormsProperty); + }); + } + + // Whatever properties weren't listed, add them at the end. + sortedProperties.addAll(propertiesToSort); + + return sortedProperties; + }) // + .orElse(properties); + } + public PropertyCustomizations forMetadata(InputPayloadMetadata metadata) { return new PropertyCustomizations(metadata); } diff --git a/src/test/java/org/springframework/hateoas/mediatype/AffordancesUnitTests.java b/src/test/java/org/springframework/hateoas/mediatype/AffordancesUnitTests.java index 68c8786da..ca585be8a 100644 --- a/src/test/java/org/springframework/hateoas/mediatype/AffordancesUnitTests.java +++ b/src/test/java/org/springframework/hateoas/mediatype/AffordancesUnitTests.java @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.*; +import java.util.Optional; import java.util.function.Consumer; import java.util.stream.Stream; @@ -113,8 +114,8 @@ public PayloadMetadataAssert(PayloadMetadata actual) { public PayloadMetadataAssert isBackedBy(Class type) { - Assertions.assertThat(actual).isInstanceOfSatisfying(TypeBasedPayloadMetadata.class, it -> { - Assertions.assertThat(it.getType()).isEqualTo(ResolvableType.forClass(type)); + assertThat(actual).isInstanceOfSatisfying(TypeBasedPayloadMetadata.class, it -> { + assertThat(it.getType()).isEqualTo(Optional.of(ResolvableType.forClass(type))); }); return this; diff --git a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsPropertyOrderingUnitTest.java b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsPropertyOrderingUnitTest.java new file mode 100644 index 000000000..77e6b1500 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsPropertyOrderingUnitTest.java @@ -0,0 +1,80 @@ +package org.springframework.hateoas.mediatype.hal.forms; + +import lombok.Data; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.RepresentationModel; +import org.springframework.hateoas.mediatype.Affordances; +import org.springframework.hateoas.mediatype.MessageResolver; +import org.springframework.http.HttpMethod; + +import static org.assertj.core.api.Assertions.*; + +public class HalFormsPropertyOrderingUnitTest { + + private RepresentationModel model; + + @BeforeEach + void setUp() { + + this.model = new RepresentationModel<>(Affordances.of(Link.of("/example")) // + .afford(HttpMethod.POST) // + .withInput(Thing.class) // + .toLink()); + } + + @Test + void noCustomOrdering() { + + HalFormsConfiguration halFormsConfiguration = new HalFormsConfiguration(); + + assertThat(createTemplate(halFormsConfiguration).getProperties()).flatExtracting(HalFormsProperty::getName) + .containsExactly("a", "b", "z"); + + } + + @Test + void specifyAllProperties() { + + HalFormsConfiguration halFormsConfiguration = new HalFormsConfiguration() // + .withFieldOrderFor(Thing.class, "z", "b", "a"); + + assertThat(createTemplate(halFormsConfiguration).getProperties()).flatExtracting(HalFormsProperty::getName) + .containsExactly("z", "b", "a"); + } + + @Test + void specifySomeProperties() { + + HalFormsConfiguration halFormsConfiguration = new HalFormsConfiguration() // + .withFieldOrderFor(Thing.class, "z"); + + assertThat(createTemplate(halFormsConfiguration).getProperties()).flatExtracting(HalFormsProperty::getName) + .containsExactly("z", "a", "b"); + } + + @Test + void nonExistentProperty() { + + HalFormsConfiguration halFormsConfiguration = new HalFormsConfiguration() // + .withFieldOrderFor(Thing.class, "q", "b"); + + assertThat(createTemplate(halFormsConfiguration).getProperties()).flatExtracting(HalFormsProperty::getName) + .containsExactly("b", "a", "z"); + } + + private HalFormsTemplate createTemplate(HalFormsConfiguration halFormsConfiguration) { + return new HalFormsTemplateBuilder(halFormsConfiguration, MessageResolver.DEFAULTS_ONLY).findTemplates(this.model) + .get("default"); + } + + @Data + private static class Thing { + + private String a; + private String b; + private String z; + } +}