diff --git a/pom.xml b/pom.xml index 82b05fd3..2850c7e2 100644 --- a/pom.xml +++ b/pom.xml @@ -759,6 +759,12 @@ test + + org.junit.jupiter + junit-jupiter-params + test + + org.springframework spring-test diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsSerializers.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsSerializers.java index 871abb7b..dce2d065 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsSerializers.java +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsSerializers.java @@ -15,12 +15,17 @@ */ package org.springframework.hateoas.mediatype.hal.forms; +import lombok.RequiredArgsConstructor; + import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.context.MessageSourceResolvable; +import org.springframework.context.support.MessageSourceAccessor; import org.springframework.hateoas.Affordance; import org.springframework.hateoas.CollectionModel; import org.springframework.hateoas.EntityModel; @@ -32,7 +37,9 @@ import org.springframework.hateoas.RepresentationModel; import org.springframework.hateoas.mediatype.hal.HalLinkRelation; import org.springframework.hateoas.mediatype.hal.Jackson2HalModule; import org.springframework.http.HttpMethod; +import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.BeanProperty; @@ -58,16 +65,18 @@ class HalFormsSerializers { private static final long serialVersionUID = -7912243216469101379L; + private final MessageSourceAccessor accessor; private final BeanProperty property; - HalFormsResourceSerializer(@Nullable BeanProperty property) { + HalFormsResourceSerializer(MessageSourceAccessor accessor, @Nullable BeanProperty property) { super(EntityModel.class, false); + this.accessor = accessor; this.property = property; } - HalFormsResourceSerializer() { - this(null); + HalFormsResourceSerializer(MessageSourceAccessor accessor) { + this(accessor, null); } /* @@ -80,7 +89,7 @@ class HalFormsSerializers { HalFormsDocument doc = HalFormsDocument.forResource(value.getContent()) // .withLinks(value.getLinks()) // - .withTemplates(findTemplates(value)); + .withTemplates(findTemplates(value, accessor)); provider.findValueSerializer(HalFormsDocument.class, property).serialize(doc, gen, provider); } @@ -134,7 +143,7 @@ class HalFormsSerializers { @SuppressWarnings("null") public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException { - return new HalFormsResourceSerializer(property); + return new HalFormsResourceSerializer(accessor, property); } } @@ -148,17 +157,20 @@ class HalFormsSerializers { private final BeanProperty property; private final Jackson2HalModule.EmbeddedMapper embeddedMapper; + private final MessageSourceAccessor accessor; - HalFormsResourcesSerializer(@Nullable BeanProperty property, Jackson2HalModule.EmbeddedMapper embeddedMapper) { + HalFormsResourcesSerializer(MessageSourceAccessor accessor, @Nullable BeanProperty property, + Jackson2HalModule.EmbeddedMapper embeddedMapper) { super(CollectionModel.class, false); this.property = property; this.embeddedMapper = embeddedMapper; + this.accessor = accessor; } - HalFormsResourcesSerializer(Jackson2HalModule.EmbeddedMapper embeddedMapper) { - this(null, embeddedMapper); + HalFormsResourcesSerializer(MessageSourceAccessor accessor, Jackson2HalModule.EmbeddedMapper embeddedMapper) { + this(accessor, null, embeddedMapper); } /* @@ -179,14 +191,14 @@ class HalFormsSerializers { .withEmbedded(embeddeds) // .withPageMetadata(((PagedModel) value).getMetadata()) // .withLinks(value.getLinks()) // - .withTemplates(findTemplates(value)); + .withTemplates(findTemplates(value, accessor)); } else { doc = HalFormsDocument.empty() // .withEmbedded(embeddeds) // .withLinks(value.getLinks()) // - .withTemplates(findTemplates(value)); + .withTemplates(findTemplates(value, accessor)); } provider.findValueSerializer(HalFormsDocument.class, property).serialize(doc, gen, provider); @@ -241,7 +253,7 @@ class HalFormsSerializers { @SuppressWarnings("null") public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException { - return new HalFormsResourcesSerializer(property, embeddedMapper); + return new HalFormsResourcesSerializer(accessor, property, embeddedMapper); } } @@ -251,14 +263,16 @@ class HalFormsSerializers { * @param resource * @return */ - private static Map findTemplates(RepresentationModel resource) { + private static Map findTemplates(RepresentationModel resource, + MessageSourceAccessor accessor) { if (!resource.hasLink(IanaLinkRelations.SELF)) { return Collections.emptyMap(); } Map templates = new HashMap<>(); - List affordances = resource.getLink(IanaLinkRelations.SELF).map(Link::getAffordances) + List affordances = resource.getLink(IanaLinkRelations.SELF) // + .map(Link::getAffordances) // .orElse(Collections.emptyList()); affordances.stream() // @@ -268,8 +282,14 @@ class HalFormsSerializers { .peek(it -> validate(resource, it)) // .forEach(it -> { + Class type = it.getInputType().resolve(Object.class); + + List propertiesWithPrompt = it.getInputProperties().stream() // + .map(property -> property.withPrompt(accessor.getMessage(PromptLookup.of(type, property)))) + .collect(Collectors.toList()); + HalFormsTemplate template = HalFormsTemplate.forMethod(it.getHttpMethod()) // - .withProperties(it.getInputProperties()); + .withProperties(propertiesWithPrompt); /* * First template in HAL-FORMS is "default". @@ -280,6 +300,40 @@ class HalFormsSerializers { return templates; } + @RequiredArgsConstructor(staticName = "of") + static class PromptLookup implements MessageSourceResolvable { + + private static final String PROMPT_TEMPLATE = "%s._prompt"; + + private final Class type; + private final HalFormsProperty property; + + /* + * (non-Javadoc) + * @see org.springframework.context.MessageSourceResolvable#getDefaultMessage() + */ + @Nullable + @Override + public String getDefaultMessage() { + return StringUtils.capitalize(property.getName()); + } + + /* + * (non-Javadoc) + * @see org.springframework.context.MessageSourceResolvable#getCodes() + */ + @NonNull + @Override + public String[] getCodes() { + + String globalCode = String.format(PROMPT_TEMPLATE, property.getName()); + String localCode = String.format("%s.%s", type.getSimpleName(), globalCode); + String qualifiedCode = String.format("%s.%s", type.getName(), globalCode); + + return new String[] { qualifiedCode, localCode, globalCode }; + } + } + /** * Verify that the resource's self link and the affordance's URI have the same relative path. * diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsModule.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsModule.java index 4da76004..f2cb3709 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsModule.java +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsModule.java @@ -23,6 +23,7 @@ import java.util.Map; import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.context.support.MessageSourceAccessor; import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; import org.springframework.hateoas.Link; import org.springframework.hateoas.Links; import org.springframework.hateoas.PagedModel; @@ -82,13 +83,15 @@ class Jackson2HalFormsModule extends SimpleModule { setMixInAnnotation(Link.class, LinkMixin.class); setMixInAnnotation(RepresentationModel.class, RepresentationModelMixin.class); + setMixInAnnotation(EntityModel.class, EntityModelMixin.class); setMixInAnnotation(CollectionModel.class, CollectionModelMixin.class); setMixInAnnotation(PagedModel.class, PagedModelMixin.class); setMixInAnnotation(MediaType.class, MediaTypeMixin.class); - - addSerializer(new HalFormsResourceSerializer()); } + @JsonSerialize(using = HalFormsResourceSerializer.class) + abstract class EntityModelMixin extends EntityModel {} + @JsonSerialize(using = HalFormsResourcesSerializer.class) abstract class CollectionModelMixin extends CollectionModel { @@ -160,7 +163,8 @@ class Jackson2HalFormsModule extends SimpleModule { EmbeddedMapper mapper = new EmbeddedMapper(resolver, curieProvider, enforceEmbeddedCollections); - this.serializers.put(HalFormsResourcesSerializer.class, new HalFormsResourcesSerializer(mapper)); + this.serializers.put(HalFormsResourceSerializer.class, new HalFormsResourceSerializer(accessor)); + this.serializers.put(HalFormsResourcesSerializer.class, new HalFormsResourcesSerializer(accessor, mapper)); this.serializers.put(HalLinkListSerializer.class, new HalLinkListSerializer(curieProvider, mapper, accessor, halFormsConfiguration.toHalConfiguration())); } diff --git a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsIntegrationTest.java b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsIntegrationTest.java index ff023f44..fd5e0c48 100644 --- a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsIntegrationTest.java @@ -18,6 +18,8 @@ package org.springframework.hateoas.mediatype.hal.forms; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +import lombok.Getter; + import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; @@ -28,6 +30,8 @@ import java.util.Locale; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.context.support.MessageSourceAccessor; @@ -53,10 +57,12 @@ import org.springframework.hateoas.server.core.AnnotationLinkRelationProvider; import org.springframework.hateoas.server.core.DelegatingLinkRelationProvider; import org.springframework.hateoas.server.core.EmbeddedWrappers; import org.springframework.hateoas.support.MappingUtils; +import org.springframework.http.HttpMethod; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.jayway.jsonpath.JsonPath; /** * @author Greg Turnquist @@ -79,7 +85,8 @@ class Jackson2HalFormsIntegrationTest extends AbstractJackson2MarshallingIntegra mapper.registerModule(new Jackson2HalFormsModule()); mapper.setHandlerInstantiator(new HalFormsHandlerInstantiator( // - provider, CurieProvider.NONE, new MessageSourceAccessor(messageSource), true, new HalFormsConfiguration())); + provider, CurieProvider.NONE, new MessageSourceAccessor(messageSource, Locale.US), true, + new HalFormsConfiguration())); mapper.configure(SerializationFeature.INDENT_OUTPUT, true); } @@ -393,6 +400,31 @@ class Jackson2HalFormsIntegrationTest extends AbstractJackson2MarshallingIntegra assertThat(deserialized).isEqualTo(original); } + @ParameterizedTest // #979 + @ValueSource(strings = { "firstname._prompt", // + "HalFormsPayload.firstname._prompt", // + "org.springframework.hateoas.mediatype.hal.forms.Jackson2HalFormsIntegrationTest$HalFormsPayload.firstname._prompt" }) + public void usesResourceBundleToCreatePropertyPrompts(String key) { + + StaticMessageSource source = new StaticMessageSource(); + source.addMessage(key, Locale.US, "Vorname"); + + Link link = new Link("some:link") // + .andAffordance(HttpMethod.POST, HalFormsPayload.class, Collections.emptyList(), Object.class); + + EntityModel model = new EntityModel<>(new HalFormsPayload(), link); + ObjectMapper mapper = getCuriedObjectMapper(CurieProvider.NONE, source); + + assertThatCode(() -> { + + String promptString = JsonPath.compile("$._templates.default.properties[0].prompt") // + .read(mapper.writeValueAsString(model)); + + assertThat(promptString).isEqualTo("Vorname"); + + }).doesNotThrowAnyException(); + } + private void verifyResolvedTitle(String resourceBundleKey) throws Exception { LocaleContextHolder.setLocale(Locale.US); @@ -448,10 +480,16 @@ class Jackson2HalFormsIntegrationTest extends AbstractJackson2MarshallingIntegra mapper.registerModule(new Jackson2HalFormsModule()); mapper.setHandlerInstantiator(new HalFormsHandlerInstantiator(new AnnotationLinkRelationProvider(), provider, - messageSource == null ? null : new MessageSourceAccessor(messageSource), true, new HalFormsConfiguration())); + messageSource == null ? null : new MessageSourceAccessor(messageSource, Locale.US), true, + new HalFormsConfiguration())); mapper.configure(SerializationFeature.INDENT_OUTPUT, true); mapper.setSerializationInclusion(Include.NON_NULL); return mapper; } + + public static class HalFormsPayload { + private @Getter String firstname; + + } }