GH-2331 - Disable single-template defaulting for HAL-FORMS.

This commit is contained in:
Oliver Drotbohm
2025-05-30 16:35:57 +02:00
parent 7a6feebe3d
commit c77993ba91
9 changed files with 110 additions and 54 deletions

View File

@@ -29,6 +29,7 @@ import org.springframework.hateoas.AffordanceModel.PropertyMetadata;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.mediatype.hal.HalConfiguration;
import org.springframework.http.MediaType;
import org.springframework.lang.Contract;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
@@ -47,6 +48,7 @@ public class HalFormsConfiguration {
private final Consumer<ObjectMapper> objectMapperCustomizer;
private final HalFormsOptionsFactory options;
private final List<MediaType> mediaTypes;
private final boolean defaultSingleTemplate;
/**
* Creates a new {@link HalFormsConfiguration} backed by a default {@link HalConfiguration}.
@@ -62,12 +64,12 @@ public class HalFormsConfiguration {
*/
public HalFormsConfiguration(HalConfiguration halConfiguration) {
this(halConfiguration, new HashMap<>(), new HalFormsOptionsFactory(), __ -> {},
Collections.singletonList(MediaTypes.HAL_FORMS_JSON));
Collections.singletonList(MediaTypes.HAL_FORMS_JSON), false);
}
private HalFormsConfiguration(HalConfiguration halConfiguration, Map<Class<?>, String> patterns,
HalFormsOptionsFactory options, @Nullable Consumer<ObjectMapper> objectMapperCustomizer,
List<MediaType> mediaTypes) {
List<MediaType> mediaTypes, boolean defaultSingleTemplate) {
Assert.notNull(halConfiguration, "HalConfiguration must not be null!");
Assert.notNull(patterns, "Patterns must not be null!");
@@ -80,6 +82,7 @@ public class HalFormsConfiguration {
this.objectMapperCustomizer = objectMapperCustomizer;
this.options = options;
this.mediaTypes = new ArrayList<>(mediaTypes);
this.defaultSingleTemplate = defaultSingleTemplate;
}
/**
@@ -97,7 +100,8 @@ public class HalFormsConfiguration {
Map<Class<?>, String> newPatterns = new HashMap<>(patterns);
newPatterns.put(type, pattern);
return new HalFormsConfiguration(halConfiguration, newPatterns, options, objectMapperCustomizer, mediaTypes);
return new HalFormsConfiguration(halConfiguration, newPatterns, options, objectMapperCustomizer, mediaTypes,
defaultSingleTemplate);
}
/**
@@ -113,7 +117,8 @@ public class HalFormsConfiguration {
return this.objectMapperCustomizer == objectMapperCustomizer //
? this //
: new HalFormsConfiguration(halConfiguration, patterns, options, objectMapperCustomizer, mediaTypes);
: new HalFormsConfiguration(halConfiguration, patterns, options, objectMapperCustomizer, mediaTypes,
defaultSingleTemplate);
}
/**
@@ -136,16 +141,18 @@ public class HalFormsConfiguration {
List<MediaType> newMediaTypes = new ArrayList<>(mediaTypes);
newMediaTypes.add(mediaTypes.size() - 1, mediaType);
return new HalFormsConfiguration(halConfiguration, patterns, options, objectMapperCustomizer, newMediaTypes);
return new HalFormsConfiguration(halConfiguration, patterns, options, objectMapperCustomizer, newMediaTypes,
defaultSingleTemplate);
}
/**
* Customizes the given {@link ObjectMapper} with the registered callback.
*
* @param mapper must not be {@literal null}.
* @return
* @return will never be {@literal null}.
* @see #withObjectMapperCustomizer(Consumer)
*/
@Contract("_ -> this")
public HalFormsConfiguration customize(ObjectMapper mapper) {
Assert.notNull(mapper, "ObjectMapper must not be null!");
@@ -168,7 +175,21 @@ public class HalFormsConfiguration {
Function<PropertyMetadata, HalFormsOptions> creator) {
return new HalFormsConfiguration(halConfiguration, patterns, options.withOptions(type, property, creator),
objectMapperCustomizer, mediaTypes);
objectMapperCustomizer, mediaTypes, defaultSingleTemplate);
}
/**
* Configures whether to use the name {@code default} in case only a single template appears. Defaults to
* {@literal false}. Set this to {@literal true} in case you need the legacy behavior.
*
* @param defaultSingleTemplate
* @return will never be {@literal null}.
* @since 3.0
*/
public HalFormsConfiguration withDefaultSingleTemplate(boolean defaultSingleTemplate) {
return new HalFormsConfiguration(halConfiguration, patterns, options, objectMapperCustomizer, mediaTypes,
defaultSingleTemplate);
}
/**
@@ -207,4 +228,15 @@ public class HalFormsConfiguration {
List<MediaType> getMediaTypes() {
return Collections.unmodifiableList(mediaTypes);
}
/**
* Returns whether to default the name of the first template added to a form. Available for backwards-compatibility
* reasons.
*
* @see https://github.com/mamund/hal-forms/issues/82
* @since 3.0
*/
boolean isDefaultSingleTemplate() {
return defaultSingleTemplate;
}
}

View File

@@ -21,7 +21,6 @@ import java.util.Optional;
import java.util.stream.Stream;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.hateoas.Affordance;
import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
import org.springframework.hateoas.IanaLinkRelations;
import org.springframework.hateoas.Link;
@@ -38,11 +37,13 @@ class HalFormsTemplateBuilder {
private final MessageResolver resolver;
private final HalFormsPropertyFactory factory;
private final HalFormsConfiguration configuration;
public HalFormsTemplateBuilder(HalFormsConfiguration configuration, MessageResolver resolver) {
this.resolver = resolver;
this.factory = new HalFormsPropertyFactory(configuration, resolver);
this.configuration = configuration;
}
/**
@@ -77,7 +78,10 @@ class HalFormsTemplateBuilder {
}
template = applyTo(template, TemplateTitle.of(it, templates.isEmpty()));
templates.put(templates.isEmpty() ? "default" : it.getName(), template);
var name = templates.isEmpty() && configuration.isDefaultSingleTemplate() ? "default" : it.getName();
templates.put(name, template);
});
return templates;

View File

@@ -67,7 +67,7 @@ class HalFormsTemplateBuilderUnitTest {
Map<String, HalFormsTemplate> templates = builder.findTemplates(resource);
HalFormsTemplate template = templates.get("default");
HalFormsTemplate template = templates.get("postPatternExample");
assertThat(template).isNotNull();
assertThat(template.getPropertyByName(propertyName) //
@@ -92,7 +92,7 @@ class HalFormsTemplateBuilderUnitTest {
Map<String, HalFormsTemplate> templates = builder.findTemplates(model);
HalFormsTemplate template = templates.get("default");
HalFormsTemplate template = templates.get("patchRequiredProperty");
assertThat(template).isNotNull();
assertThat(template.getPropertyByName("name").map(HalFormsProperty::isRequired)).hasValue(false);
@@ -112,7 +112,7 @@ class HalFormsTemplateBuilderUnitTest {
.toLink();
HalFormsTemplate template = new HalFormsTemplateBuilder(new HalFormsConfiguration(), //
MessageResolver.DEFAULTS_ONLY).findTemplates(new RepresentationModel<>().add(link)).get("default");
MessageResolver.DEFAULTS_ONLY).findTemplates(new RepresentationModel<>().add(link)).get("postPayload");
Optional<HalFormsProperty> number = template.getPropertyByName("number");
assertThat(number).map(HalFormsProperty::getMin).hasValue(2L);
@@ -136,7 +136,7 @@ class HalFormsTemplateBuilderUnitTest {
.toLink();
HalFormsTemplate template = new HalFormsTemplateBuilder(new HalFormsConfiguration(), //
MessageResolver.DEFAULTS_ONLY).findTemplates(new RepresentationModel<>().add(link)).get("default");
MessageResolver.DEFAULTS_ONLY).findTemplates(new RepresentationModel<>().add(link)).get("postPayload");
Optional<HalFormsProperty> decimal = template.getPropertyByName("decimal");
assertThat(decimal).map(HalFormsProperty::getMin).hasValue(new BigDecimal("2.1"));
@@ -155,7 +155,7 @@ class HalFormsTemplateBuilderUnitTest {
Map<String, HalFormsTemplate> templates = new HalFormsTemplateBuilder(new HalFormsConfiguration(),
MessageResolver.DEFAULTS_ONLY).findTemplates(new RepresentationModel<>().add(link));
assertThat(templates.get("default").getTarget()).isEqualTo("/example");
assertThat(templates.get("create").getTarget()).isEqualTo("/example");
}
@Test // #1443
@@ -173,7 +173,7 @@ class HalFormsTemplateBuilderUnitTest {
Map<String, HalFormsTemplate> templates = new HalFormsTemplateBuilder(new HalFormsConfiguration(),
MessageResolver.DEFAULTS_ONLY).findTemplates(new RepresentationModel<>().add(link));
assertThat(templates.get("default").getContentType()).isEqualTo(mediaType.toString());
assertThat(templates.get("create").getContentType()).isEqualTo(mediaType.toString());
}
@Test // #1483
@@ -193,7 +193,7 @@ class HalFormsTemplateBuilderUnitTest {
Map<String, HalFormsTemplate> templates = new HalFormsTemplateBuilder(configuration, MessageResolver.DEFAULTS_ONLY)
.findTemplates(models);
assertThat(templates.get("default").getPropertyByName("number")) //
assertThat(templates.get("postPatternExample").getPropertyByName("number")) //
.hasValueSatisfying(it -> {
assertThat(it.getOptions()).isNotNull() //
.isInstanceOfSatisfying(HalFormsOptions.Inline.class,
@@ -220,7 +220,7 @@ class HalFormsTemplateBuilderUnitTest {
Map<String, HalFormsTemplate> templates = new HalFormsTemplateBuilder(configuration, MessageResolver.DEFAULTS_ONLY)
.findTemplates(models);
assertThat(templates.get("default").getPropertyByName("number")).hasValueSatisfying(it -> {
assertThat(templates.get("postPatternExample").getPropertyByName("number")).hasValueSatisfying(it -> {
assertThat(it.getValue()).isEqualTo(selected);
});
}
@@ -243,7 +243,7 @@ class HalFormsTemplateBuilderUnitTest {
Map<String, HalFormsTemplate> templates = new HalFormsTemplateBuilder(configuration, MessageResolver.DEFAULTS_ONLY)
.findTemplates(models);
assertThat(templates.get("default").getTarget()).endsWith("/example");
assertThat(templates.get("post").getTarget()).endsWith("/example");
}
@Test // #1697
@@ -258,11 +258,29 @@ class HalFormsTemplateBuilderUnitTest {
Map<String, HalFormsTemplate> templates = new HalFormsTemplateBuilder(new HalFormsConfiguration(),
MessageResolver.DEFAULTS_ONLY).findTemplates(models);
assertThat(templates.get("default").getPropertyByName("property")).hasValueSatisfying(it -> {
assertThat(templates.get("postWithCustomInputType").getPropertyByName("property")).hasValueSatisfying(it -> {
assertThat(it.getType()).isEqualTo("custom");
});
}
@Test
void usesDefaultAsSingleTemplateNameIfConfigured() {
var model = new RepresentationModel<>(
Affordances.of(Link.of("/example", LinkRelation.of("example"))) //
.afford(HttpMethod.POST) //
.withName("name") //
.withInput(WithCustomInputType.class) //
.toLink());
var configuration = new HalFormsConfiguration().withDefaultSingleTemplate(true);
var builder = new HalFormsTemplateBuilder(configuration, MessageResolver.DEFAULTS_ONLY);
assertThat(builder.findTemplates(model))
.containsKey("default")
.doesNotContainKey("name");
}
@Getter
static class PatternExample extends RepresentationModel<PatternExample> {

View File

@@ -70,7 +70,7 @@ class HalFormsWebFluxIntegrationTest {
.expectStatus().isOk() //
.expectHeader().contentType(MediaTypes.HAL_FORMS_JSON) //
.expectBody(String.class)//
.expectBody(String.class) //
.value(jsonPath("$.name", is("Frodo Baggins"))) //
.value(jsonPath("$.role", is("ring bearer"))) //
@@ -80,11 +80,11 @@ class HalFormsWebFluxIntegrationTest {
.value(jsonPath("$._links['employees'].href", is("http://localhost/employees"))) //
.value(jsonPath("$._templates.*", hasSize(2))) //
.value(jsonPath("$._templates['default'].method", is("PUT"))) //
.value(jsonPath("$._templates['default'].properties[0].name", is("name"))) //
.value(jsonPath("$._templates['default'].properties[0].required", is(true))) //
.value(jsonPath("$._templates['default'].properties[1].name", is("role"))) //
.value(jsonPath("$._templates['default'].properties[1].required").doesNotExist()) //
.value(jsonPath("$._templates['updateEmployee'].method", is("PUT"))) //
.value(jsonPath("$._templates['updateEmployee'].properties[0].name", is("name"))) //
.value(jsonPath("$._templates['updateEmployee'].properties[0].required", is(true))) //
.value(jsonPath("$._templates['updateEmployee'].properties[1].name", is("role"))) //
.value(jsonPath("$._templates['updateEmployee'].properties[1].required").doesNotExist()) //
.value(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("PATCH"))) //
.value(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].name", is("name"))) //
@@ -111,11 +111,12 @@ class HalFormsWebFluxIntegrationTest {
.value(jsonPath("$._links.*", hasSize(1)))
.value(jsonPath("$._links['self'].href", is("http://localhost/employees")))
.value(jsonPath("$._templates.*", hasSize(1))).value(jsonPath("$._templates['default'].method", is("POST")))
.value(jsonPath("$._templates['default'].properties[0].name", is("name")))
.value(jsonPath("$._templates['default'].properties[0].required", is(true)))
.value(jsonPath("$._templates['default'].properties[1].name", is("role")))
.value(jsonPath("$._templates['default'].properties[1].required").doesNotExist());
.value(jsonPath("$._templates.*", hasSize(1)))
.value(jsonPath("$._templates['newEmployee'].method", is("POST")))
.value(jsonPath("$._templates['newEmployee'].properties[0].name", is("name")))
.value(jsonPath("$._templates['newEmployee'].properties[0].required", is(true)))
.value(jsonPath("$._templates['newEmployee'].properties[1].name", is("role")))
.value(jsonPath("$._templates['newEmployee'].properties[1].required").doesNotExist());
}
/**

View File

@@ -40,6 +40,7 @@ import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@@ -76,11 +77,11 @@ class HalFormsWebMvcIntegrationTest {
.andExpect(jsonPath("$._links['employees'].href", is("http://localhost/employees")))
.andExpect(jsonPath("$._templates.*", hasSize(2)))
.andExpect(jsonPath("$._templates['default'].method", is("PUT")))
.andExpect(jsonPath("$._templates['default'].properties[0].name", is("name")))
.andExpect(jsonPath("$._templates['default'].properties[0].required").value(true))
.andExpect(jsonPath("$._templates['default'].properties[1].name", is("role")))
.andExpect(jsonPath("$._templates['default'].properties[1].required").doesNotExist())
.andExpect(jsonPath("$._templates['updateEmployee'].method", is("PUT")))
.andExpect(jsonPath("$._templates['updateEmployee'].properties[0].name", is("name")))
.andExpect(jsonPath("$._templates['updateEmployee'].properties[0].required").value(true))
.andExpect(jsonPath("$._templates['updateEmployee'].properties[1].name", is("role")))
.andExpect(jsonPath("$._templates['updateEmployee'].properties[1].required").doesNotExist())
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("PATCH")))
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].name", is("name")))
@@ -105,11 +106,11 @@ class HalFormsWebMvcIntegrationTest {
.andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees")))
.andExpect(jsonPath("$._templates.*", hasSize(1)))
.andExpect(jsonPath("$._templates['default'].method", is("POST")))
.andExpect(jsonPath("$._templates['default'].properties[0].name", is("name")))
.andExpect(jsonPath("$._templates['default'].properties[0].required").value(true))
.andExpect(jsonPath("$._templates['default'].properties[1].name", is("role")))
.andExpect(jsonPath("$._templates['default'].properties[1].required").doesNotExist());
.andExpect(jsonPath("$._templates['newEmployee'].method", is("POST")))
.andExpect(jsonPath("$._templates['newEmployee'].properties[0].name", is("name")))
.andExpect(jsonPath("$._templates['newEmployee'].properties[0].required").value(true))
.andExpect(jsonPath("$._templates['newEmployee'].properties[1].name", is("role")))
.andExpect(jsonPath("$._templates['newEmployee'].properties[1].required").doesNotExist());
}
@Test

View File

@@ -449,7 +449,7 @@ class Jackson2HalFormsIntegrationTest {
assertThatCode(() -> {
String promptString = JsonPath.compile("$._templates.default.properties[0].prompt") //
String promptString = JsonPath.compile("$._templates.sample.properties[0].prompt") //
.read(mapper.writeObject(model));
assertThat(promptString).isEqualTo("Vorname");
@@ -480,7 +480,7 @@ class Jackson2HalFormsIntegrationTest {
assertThatCode(() -> {
String promptString = JsonPath.compile("$._templates.default.title") //
String promptString = JsonPath.compile("$._templates.postHalFormsPayload.title") //
.read(mapper.writeObject(model));
assertThat(promptString).isEqualTo("Template title");
@@ -516,9 +516,9 @@ class Jackson2HalFormsIntegrationTest {
EntityModel<Jsr303Sample> model = EntityModel.of(new Jsr303Sample(), link);
assertValueForPath(model, "$._templates.default.properties[0].readOnly", true);
assertValueForPath(model, "$._templates.default.properties[0].regex", "[\\w\\s]");
assertValueForPath(model, "$._templates.default.properties[0].required", true);
assertValueForPath(model, "$._templates.postJsr303Sample.properties[0].readOnly", true);
assertValueForPath(model, "$._templates.postJsr303Sample.properties[0].regex", "[\\w\\s]");
assertValueForPath(model, "$._templates.postJsr303Sample.properties[0].required", true);
}
@Test // #968
@@ -551,7 +551,7 @@ class Jackson2HalFormsIntegrationTest {
assertThatCode(() -> {
String promptString = JsonPath.compile("$._templates.default.properties[0].placeholder") //
String promptString = JsonPath.compile("$._templates.postHalFormsPayload.properties[0].placeholder") //
.read(mapper.writeObject(model));
assertThat(promptString).isEqualTo("Property placeholder");

View File

@@ -207,8 +207,8 @@ class MultiMediaTypeWebMvcIntegrationTest {
.andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees/0")))
.andExpect(jsonPath("$._links['employees'].href", is("http://localhost/employees")));
expectEmployeeProperties(actions, "default", "partiallyUpdateEmployee") //
.andExpect(jsonPath("$._templates['default'].method", is("PUT")))
expectEmployeeProperties(actions, "updateEmployee", "partiallyUpdateEmployee") //
.andExpect(jsonPath("$._templates['updateEmployee'].method", is("PUT")))
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("PATCH")));
}
@@ -227,9 +227,9 @@ class MultiMediaTypeWebMvcIntegrationTest {
.andExpect(jsonPath("$._links.*", hasSize(1)))
.andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees")))
.andExpect(jsonPath("$._templates['default'].method", is("POST")));
.andExpect(jsonPath("$._templates['newEmployee'].method", is("POST")));
expectEmployeeProperties(actions, "default");
expectEmployeeProperties(actions, "newEmployee");
}
@Test
@@ -249,9 +249,9 @@ class MultiMediaTypeWebMvcIntegrationTest {
.andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees/2")))
.andExpect(jsonPath("$._links['employees'].href", is("http://localhost/employees")));
expectEmployeeProperties(actions, "default", "partiallyUpdateEmployee") //
expectEmployeeProperties(actions, "updateEmployee", "partiallyUpdateEmployee") //
.andExpect(jsonPath("$._templates['default'].method", is("PUT")))
.andExpect(jsonPath("$._templates['updateEmployee'].method", is("PUT")))
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("PATCH")));
}

View File

@@ -6,7 +6,7 @@
}
},
"_templates" : {
"default" : {
"foo" : {
"method" : "POST",
"properties" : [ {
"name" : "name",

View File

@@ -10,7 +10,7 @@
}
},
"_templates" : {
"default" : {
"updateEmployee" : {
"method" : "PUT",
"properties" : [ {
"name" : "name",