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;
+ }
+}