diff --git a/src/main/java/org/springframework/hateoas/AffordanceModel.java b/src/main/java/org/springframework/hateoas/AffordanceModel.java index cb930d43..370b8920 100644 --- a/src/main/java/org/springframework/hateoas/AffordanceModel.java +++ b/src/main/java/org/springframework/hateoas/AffordanceModel.java @@ -19,11 +19,14 @@ import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.function.BiFunction; import java.util.function.Function; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.springframework.core.ResolvableType; import org.springframework.http.HttpMethod; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** @@ -134,20 +137,35 @@ public abstract class AffordanceModel { return this.output; } + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ @Override - public boolean equals(Object o) { + public boolean equals(@Nullable Object o) { - if (this == o) + if (this == o) { return true; - if (o == null || getClass() != o.getClass()) + } + + if (o == null || getClass() != o.getClass()) { return false; + } + AffordanceModel that = (AffordanceModel) o; - return Objects.equals(this.name, that.name) && Objects.equals(this.link, that.link) - && this.httpMethod == that.httpMethod && Objects.equals(this.input, that.input) - && Objects.equals(this.queryMethodParameters, that.queryMethodParameters) + + return Objects.equals(this.name, that.name) // + && Objects.equals(this.link, that.link) // + && this.httpMethod == that.httpMethod // + && Objects.equals(this.input, that.input) // + && Objects.equals(this.queryMethodParameters, that.queryMethodParameters) // && Objects.equals(this.output, that.output); } + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ @Override public int hashCode() { return Objects.hash(this.name, this.link, this.httpMethod, this.input, this.queryMethodParameters, this.output); @@ -190,6 +208,32 @@ public abstract class AffordanceModel { : 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 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 & Named> List createProperties( + Function creator, + BiFunction 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. * @@ -197,7 +241,12 @@ public abstract class AffordanceModel { * @param target * @return */ - & Named> T applyTo(T target); + default & Named> T applyTo(T target) { + + return getPropertyMetadata(target.getName()) // + .map(it -> target.apply(it)) // + .orElse(target); + } T customize(T target, Function customizer); @@ -262,14 +311,23 @@ public abstract class AffordanceModel { return Collections.emptyList(); } + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ @Override - public boolean equals(Object o) { + public boolean equals(@Nullable Object o) { - if (this == o) + if (this == o) { return true; - if (o == null || getClass() != o.getClass()) + } + + if (o == null || getClass() != o.getClass()) { return false; + } + DelegatingInputPayloadMetadata that = (DelegatingInputPayloadMetadata) o; + return Objects.equals(this.metadata, that.metadata); } @@ -278,6 +336,7 @@ public abstract class AffordanceModel { return Objects.hash(this.metadata); } + @Override public String toString() { return "AffordanceModel.DelegatingInputPayloadMetadata(metadata=" + this.metadata + ")"; } diff --git a/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java b/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java index 2f0f506b..44752a4a 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java +++ b/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java @@ -27,7 +27,6 @@ import org.springframework.core.ResolvableType; import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata; import org.springframework.hateoas.AffordanceModel.Named; import org.springframework.hateoas.AffordanceModel.PropertyMetadata; -import org.springframework.hateoas.AffordanceModel.PropertyMetadataConfigured; /** * {@link InputPayloadMetadata} implementation based on a Java type. @@ -46,18 +45,6 @@ class TypeBasedPayloadMetadata implements InputPayloadMetadata { properties.collect(Collectors.toMap(PropertyMetadata::getName, Function.identity()))); } - /* - * (non-Javadoc) - * @see org.springframework.hateoas.mediatype.PayloadMetadata#customize(T) - */ - @Override - public & Named> T applyTo(T target) { - - PropertyMetadata metadata = this.properties.get(target.getName()); - - return metadata == null ? target : target.apply(metadata); - } - /* * (non-Javadoc) * @see org.springframework.hateoas.AffordanceModel.PayloadMetadata#customize(org.springframework.hateoas.AffordanceModel.Named, java.util.function.Function) diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModel.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModel.java index 4aa193d6..221e45e7 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModel.java +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModel.java @@ -17,19 +17,25 @@ 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.Objects; +import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; +import java.util.function.BiFunction; +import java.util.function.Function; -import org.springframework.hateoas.Affordance; +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}. @@ -41,52 +47,119 @@ class HalFormsAffordanceModel extends AffordanceModel { private static final Set ENTITY_ALTERING_METHODS = EnumSet.of(POST, PUT, PATCH); - private final List inputProperties; - public HalFormsAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType, List queryMethodParameters, PayloadMetadata outputType) { - super(name, link, httpMethod, inputType, queryMethodParameters, outputType); - - this.inputProperties = determineInputs(); } /** - * Look at the input's domain type to extract the {@link Affordance}'s properties. Then transform them into a list of - * {@link HalFormsProperty} objects. + * Applies the given customizer to all {@link HalFormsProperty} of this model. + * + * @param customizer must not be {@literal null}. + * @return */ - private List determineInputs() { + public List getProperties(HalFormsConfiguration configuration, MessageResolver resolver) { if (!ENTITY_ALTERING_METHODS.contains(getHttpMethod())) { return Collections.emptyList(); } - return getInput().stream() // - .map(PropertyMetadata::getName) // - .map(it -> new HalFormsProperty() // - .withName(it)) // - .collect(Collectors.toList()); + Function 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); + }); } - public List getInputProperties() { - return this.inputProperties; + private HalFormsProperty apply(HalFormsProperty property, + BiFunction creator, + Function 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); } - @Override - public boolean equals(Object o) { + private static class I18nedPropertyMetadata implements MessageSourceResolvable { - if (this == o) - return true; - if (!(o instanceof HalFormsAffordanceModel)) - return false; - if (!super.equals(o)) - return false; - HalFormsAffordanceModel that = (HalFormsAffordanceModel) o; - return Objects.equals(this.inputProperties, that.inputProperties); + 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 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]); + } } - @Override - public int hashCode() { - return Objects.hash(super.hashCode(), inputProperties); + 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); + } } } diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsProperty.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsProperty.java index 0b350750..66502009 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsProperty.java +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsProperty.java @@ -20,8 +20,11 @@ import java.util.Objects; 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; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; @@ -35,14 +38,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; @JsonInclude(Include.NON_DEFAULT) final class HalFormsProperty implements PropertyMetadataConfigured, Named { - private final String name; - private @JsonInclude(Include.NON_DEFAULT) final boolean readOnly; - private final String value; - private @JsonInclude(Include.NON_EMPTY) final String prompt; - private final String regex; - private final boolean templated; - private @JsonInclude(Include.NON_DEFAULT) final boolean required; - private final boolean multi; + private final String name, value, prompt, regex, placeholder; + private final boolean templated, multi; + private final @JsonInclude(Include.NON_DEFAULT) boolean readOnly, required; HalFormsProperty() { @@ -54,21 +52,23 @@ final class HalFormsProperty implements PropertyMetadataConfigured !it.hasHttpMethod(HttpMethod.GET)) // .forEach(it -> { - PropertyCustomizations propertyCustomizations = forMetadata(it.getInput()); - - List propertiesWithPrompt = it.getInputProperties().stream() // - .map(property -> propertyCustomizations.apply(property)) // - .map(property -> it.hasHttpMethod(HttpMethod.PATCH) ? property.withRequired(false) : property) - .collect(Collectors.toList()); - HalFormsTemplate template = HalFormsTemplate.forMethod(it.getHttpMethod()) // - .withProperties(propertiesWithPrompt); + .withProperties(it.getProperties(configuration, resolver)); template = applyTo(template, TemplateTitle.of(it, templates.isEmpty())); templates.put(templates.isEmpty() ? "default" : it.getName(), template); @@ -92,46 +82,14 @@ class HalFormsTemplateBuilder { return templates; } - public PropertyCustomizations forMetadata(InputPayloadMetadata metadata) { - return new PropertyCustomizations(metadata); - } - - public HalFormsTemplate applyTo(HalFormsTemplate template, HalFormsTemplateBuilder.TemplateTitle templateTitle) { + private HalFormsTemplate applyTo(HalFormsTemplate template, HalFormsTemplateBuilder.TemplateTitle templateTitle) { return Optional.ofNullable(resolver.resolve(templateTitle)) // .map(template::withTitle) // .orElse(template); } - class PropertyCustomizations { - - private final InputPayloadMetadata metadata; - - public PropertyCustomizations(InputPayloadMetadata metadata) { - this.metadata = metadata; - } - - private HalFormsProperty apply(HalFormsProperty property) { - - String message = resolver.resolve(PropertyPrompt.of(metadata, property)); - - HalFormsProperty withPrompt = Optional.ofNullable(message) // - .map(it -> property.withPrompt(it)) // - .orElse(property); - - HalFormsProperty withConfig = metadata.getPropertyMetadata(withPrompt.getName()) // - .flatMap(it -> applyConfig(it, withPrompt)) // - .orElse(withPrompt); - - return metadata.applyTo(withConfig); - } - - private Optional applyConfig(PropertyMetadata metadata, HalFormsProperty property) { - return configuration.getTypePatternFor(metadata.getType()).map(property::withRegex); - } - } - - static class TemplateTitle implements MessageSourceResolvable { + private static class TemplateTitle implements MessageSourceResolvable { private static final String TEMPLATE_TEMPLATE = "_templates.%s.title"; @@ -184,53 +142,4 @@ class HalFormsTemplateBuilder { return ""; } } - - static class PropertyPrompt implements MessageSourceResolvable { - - private static final String PROMPT_TEMPLATE = "%s._prompt"; - - private final InputPayloadMetadata metadata; - private final HalFormsProperty property; - - private PropertyPrompt(InputPayloadMetadata metadata, HalFormsProperty property) { - - this.metadata = metadata; - this.property = property; - } - - public static PropertyPrompt of(InputPayloadMetadata metadata, HalFormsProperty property) { - return new PropertyPrompt(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(PROMPT_TEMPLATE, property.getName()); - - List 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]); - } - } } diff --git a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilderUnitTest.java b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilderUnitTest.java index 10ecda7c..81ba7ad6 100644 --- a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilderUnitTest.java +++ b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilderUnitTest.java @@ -42,8 +42,8 @@ class HalFormsTemplateBuilderUnitTest { @CsvSource({ "number, [0-9]{16}", "overridden, foo", "annotated, bar" }) void detectsRegularExpressionsOnProperties(String propertyName, String expected) { - HalFormsConfiguration configuration = new HalFormsConfiguration(); - configuration.registerPattern(CreditCardNumber.class, "[0-9]{16}"); + HalFormsConfiguration configuration = new HalFormsConfiguration() // + .withPattern(CreditCardNumber.class, "[0-9]{16}"); HalFormsTemplateBuilder builder = new HalFormsTemplateBuilder(configuration, MessageResolver.DEFAULTS_ONLY); 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 3aeea9a6..aadc642b 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 @@ -87,6 +87,8 @@ class Jackson2HalFormsIntegrationTest extends AbstractJackson2MarshallingIntegra @BeforeEach void setUpModule() { + LocaleContextHolder.setLocale(Locale.US); + LinkRelationProvider provider = new DelegatingLinkRelationProvider(new AnnotationLinkRelationProvider(), HalTestUtils.DefaultLinkRelationProvider.INSTANCE); @@ -528,6 +530,33 @@ class Jackson2HalFormsIntegrationTest extends AbstractJackson2MarshallingIntegra assertValueForPath(unwrappedExample, "$.firstname", "john"); } + @ParameterizedTest // #1438 + @ValueSource(strings = { "firstname._placeholder", // + "HalFormsPayload.firstname._placeholder", // + "org.springframework.hateoas.mediatype.hal.forms.Jackson2HalFormsIntegrationTest$HalFormsPayload.firstname._placeholder" }) + void usesResourceBundleToCreatePropertyPlaceholder(String key) { + + StaticMessageSource source = new StaticMessageSource(); + source.addMessage(key, Locale.US, "Property placeholder"); + + Link link = Affordances.of(Link.of("some:link")) // + .afford(HttpMethod.POST) // + .withInput(HalFormsPayload.class) // + .toLink(); + + EntityModel model = EntityModel.of(new HalFormsPayload(), link); + ObjectMapper mapper = getCuriedObjectMapper(CurieProvider.NONE, source); + + assertThatCode(() -> { + + String promptString = JsonPath.compile("$._templates.default.properties[0].placeholder") // + .read(mapper.writeValueAsString(model)); + + assertThat(promptString).isEqualTo("Property placeholder"); + + }).doesNotThrowAnyException(); + } + private void assertThatPathDoesNotExist(Object toMarshall, String path) throws Exception { ObjectMapper mapper = getCuriedObjectMapper(); @@ -553,8 +582,6 @@ class Jackson2HalFormsIntegrationTest extends AbstractJackson2MarshallingIntegra private void verifyResolvedTitle(String resourceBundleKey) throws Exception { - LocaleContextHolder.setLocale(Locale.US); - StaticMessageSource messageSource = new StaticMessageSource(); messageSource.addMessage(resourceBundleKey, Locale.US, "Foobar's title!");