From 94a50786f488f42ccffd330293e7f43f31ef18b1 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Wed, 27 Jan 2021 19:12:56 +0100 Subject: [PATCH] #1483 - Support for HAL-FORMS options element. We now support defining the options element [0] for a HAL-FORMS property by registering a lookup of HalFormsOptions instances on HalFormsConfiguration. [0] https://rwcbook.github.io/hal-forms/#options-element --- .../hateoas/AffordanceModel.java | 17 +- .../hateoas/mediatype/Affordances.java | 10 +- .../MessageSourceResolvableSerializer.java | 61 ++++ .../hateoas/mediatype/PropertyUtils.java | 2 +- .../mediatype/TypeBasedPayloadMetadata.java | 12 +- .../mediatype/hal/Jackson2HalModule.java | 2 + .../hal/forms/HalFormsConfiguration.java | 45 ++- .../mediatype/hal/forms/HalFormsOptions.java | 333 ++++++++++++++++++ .../hal/forms/HalFormsOptionsFactory.java | 116 ++++++ .../hal/forms/HalFormsPromptedValue.java | 118 +++++++ .../mediatype/hal/forms/HalFormsProperty.java | 93 +++-- .../hal/forms/HalFormsPropertyFactory.java | 5 +- .../mediatype/AffordancesUnitTests.java | 3 +- .../HalFormsTemplateBuilderUnitTest.java | 27 ++ .../Jackson2HalFormsIntegrationTest.java | 32 ++ 15 files changed, 823 insertions(+), 53 deletions(-) create mode 100644 src/main/java/org/springframework/hateoas/mediatype/MessageSourceResolvableSerializer.java create mode 100644 src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsOptions.java create mode 100644 src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsOptionsFactory.java create mode 100644 src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsPromptedValue.java diff --git a/src/main/java/org/springframework/hateoas/AffordanceModel.java b/src/main/java/org/springframework/hateoas/AffordanceModel.java index 05292e3e..1c635d67 100644 --- a/src/main/java/org/springframework/hateoas/AffordanceModel.java +++ b/src/main/java/org/springframework/hateoas/AffordanceModel.java @@ -207,6 +207,11 @@ public abstract class AffordanceModel { default Optional getPropertyMetadata(String name) { return stream().filter(it -> it.hasName(name)).findFirst(); } + + @Nullable + default Class getType() { + return null; + } } /** @@ -355,6 +360,16 @@ public abstract class AffordanceModel { return mediaTypes; } + /* + * (non-Javadoc) + * @see org.springframework.hateoas.AffordanceModel.InputPayloadMetadata#getType() + */ + @Nullable + @Override + public Class getType() { + return metadata.getType(); + } + /* * (non-Javadoc) * @see java.lang.Object#equals(java.lang.Object) @@ -391,7 +406,7 @@ public abstract class AffordanceModel { * * @author Oliver Drotbohm */ - public interface PropertyMetadata { + public interface PropertyMetadata extends Named { /** * The name of the property. diff --git a/src/main/java/org/springframework/hateoas/mediatype/Affordances.java b/src/main/java/org/springframework/hateoas/mediatype/Affordances.java index f0f4d382..3ba07655 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/Affordances.java +++ b/src/main/java/org/springframework/hateoas/mediatype/Affordances.java @@ -325,17 +325,11 @@ public class Affordances implements AffordanceOperations { String name = method.toString().toLowerCase(); - ResolvableType type = TypeBasedPayloadMetadata.class.isInstance(inputMetdata) // + Class 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 type == null ? name : name.concat(type.getSimpleName()); } /* diff --git a/src/main/java/org/springframework/hateoas/mediatype/MessageSourceResolvableSerializer.java b/src/main/java/org/springframework/hateoas/mediatype/MessageSourceResolvableSerializer.java new file mode 100644 index 00000000..a0abfc25 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mediatype/MessageSourceResolvableSerializer.java @@ -0,0 +1,61 @@ +/* + * Copyright 2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.mediatype; + +import java.io.IOException; + +import org.springframework.context.MessageSourceResolvable; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +/** + * A Jackson serializer triggering message resolution via a {@link MessageResolver} for {@link MessageSourceResolvable} + * instances about to be serialized. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +public class MessageSourceResolvableSerializer extends StdSerializer { + + private static final long serialVersionUID = 4302540100251549622L; + + private final MessageResolver resolver; + + /** + * Creates a new {@link MessageSourceResolvableSerializer} for the given {@link MessageResolver}. + * + * @param resolver must not be {@literal null}. + */ + public MessageSourceResolvableSerializer(MessageResolver resolver) { + + super(MessageSourceResolvable.class); + + this.resolver = resolver; + } + + /* + * (non-Javadoc) + * @see com.fasterxml.jackson.databind.ser.std.StdSerializer#serialize(java.lang.Object, com.fasterxml.jackson.core.JsonGenerator, com.fasterxml.jackson.databind.SerializerProvider) + */ + @Override + @SuppressWarnings("null") + public void serialize(MessageSourceResolvable value, JsonGenerator gen, SerializerProvider provider) + throws IOException { + gen.writeString(resolver.resolve(value)); + } +} diff --git a/src/main/java/org/springframework/hateoas/mediatype/PropertyUtils.java b/src/main/java/org/springframework/hateoas/mediatype/PropertyUtils.java index af9ee99f..85aedfef 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/PropertyUtils.java +++ b/src/main/java/org/springframework/hateoas/mediatype/PropertyUtils.java @@ -137,7 +137,7 @@ public class PropertyUtils { return Object.class.equals(resolved) // ? InputPayloadMetadata.NONE // - : new TypeBasedPayloadMetadata(domainType, lookupExposedProperties(resolved)); + : new TypeBasedPayloadMetadata(resolved, lookupExposedProperties(resolved)); }); } diff --git a/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java b/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java index 4f4c8477..ace5ec57 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java +++ b/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java @@ -24,7 +24,6 @@ import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.springframework.core.ResolvableType; import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata; import org.springframework.hateoas.AffordanceModel.Named; import org.springframework.hateoas.AffordanceModel.PropertyMetadata; @@ -38,16 +37,16 @@ import org.springframework.util.Assert; */ class TypeBasedPayloadMetadata implements InputPayloadMetadata { - private final ResolvableType type; + private final Class type; private final SortedMap properties; private final List mediaTypes; - TypeBasedPayloadMetadata(ResolvableType type, Stream properties) { + TypeBasedPayloadMetadata(Class type, Stream properties) { this(type, new TreeMap<>( properties.collect(Collectors.toMap(PropertyMetadata::getName, Function.identity()))), Collections.emptyList()); } - TypeBasedPayloadMetadata(ResolvableType type, SortedMap properties, + TypeBasedPayloadMetadata(Class type, SortedMap properties, List mediaTypes) { Assert.notNull(type, "Type must not be null!"); @@ -86,13 +85,10 @@ class TypeBasedPayloadMetadata implements InputPayloadMetadata { */ @Override public List getI18nCodes() { - - Class type = this.type.resolve(Object.class); - return Arrays.asList(type.getName(), type.getSimpleName()); } - ResolvableType getType() { + public Class getType() { return this.type; } diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/Jackson2HalModule.java b/src/main/java/org/springframework/hateoas/mediatype/hal/Jackson2HalModule.java index 029880fe..9db3d088 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/hal/Jackson2HalModule.java +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/Jackson2HalModule.java @@ -36,6 +36,7 @@ import org.springframework.hateoas.Links; import org.springframework.hateoas.RepresentationModel; import org.springframework.hateoas.mediatype.ConfigurableHandlerInstantiator; import org.springframework.hateoas.mediatype.MessageResolver; +import org.springframework.hateoas.mediatype.MessageSourceResolvableSerializer; import org.springframework.hateoas.mediatype.hal.HalConfiguration.RenderSingleLinks; import org.springframework.hateoas.server.LinkRelationProvider; import org.springframework.lang.Nullable; @@ -742,6 +743,7 @@ public class Jackson2HalModule extends SimpleModule { registerInstance(new HalResourcesSerializer(mapper, halConfiguration)); registerInstance(new HalLinkListSerializer(curieProvider, mapper, resolver, halConfiguration)); + registerInstance(new MessageSourceResolvableSerializer(resolver)); } } 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 9d360af3..77f207f2 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 @@ -19,8 +19,10 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.function.Consumer; +import java.util.function.Function; import org.springframework.core.ResolvableType; +import org.springframework.hateoas.AffordanceModel.PropertyMetadata; import org.springframework.hateoas.mediatype.hal.HalConfiguration; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -38,6 +40,7 @@ public class HalFormsConfiguration { private final HalConfiguration halConfiguration; private final Map, String> patterns; private final Consumer objectMapperCustomizer; + private final HalFormsOptionsFactory options; /** * Creates a new {@link HalFormsConfiguration} backed by a default {@link HalConfiguration}. @@ -52,19 +55,21 @@ public class HalFormsConfiguration { * @param halConfiguration must not be {@literal null}. */ public HalFormsConfiguration(HalConfiguration halConfiguration) { - this(halConfiguration, new HashMap<>(), __ -> {}); + this(halConfiguration, new HashMap<>(), new HalFormsOptionsFactory(), __ -> {}); } private HalFormsConfiguration(HalConfiguration halConfiguration, Map, String> patterns, - @Nullable Consumer objectMapperCustomizer) { + HalFormsOptionsFactory options, @Nullable Consumer objectMapperCustomizer) { Assert.notNull(halConfiguration, "HalConfiguration must not be null!"); Assert.notNull(patterns, "Patterns must not be null!"); Assert.notNull(objectMapperCustomizer, "ObjectMapper customizer must not be null!"); + Assert.notNull(options, "HalFormsSuggests must not be null!"); this.halConfiguration = halConfiguration; this.patterns = patterns; this.objectMapperCustomizer = objectMapperCustomizer; + this.options = options; } /** @@ -101,7 +106,7 @@ public class HalFormsConfiguration { Map, String> newPatterns = new HashMap<>(patterns); newPatterns.put(type, pattern); - return new HalFormsConfiguration(halConfiguration, newPatterns, objectMapperCustomizer); + return new HalFormsConfiguration(halConfiguration, newPatterns, options, objectMapperCustomizer); } /** @@ -117,7 +122,7 @@ public class HalFormsConfiguration { return this.objectMapperCustomizer == objectMapperCustomizer // ? this // - : new HalFormsConfiguration(halConfiguration, patterns, objectMapperCustomizer); + : new HalFormsConfiguration(halConfiguration, patterns, options, objectMapperCustomizer); } /** @@ -136,10 +141,40 @@ public class HalFormsConfiguration { return this; } - public HalConfiguration getHalConfiguration() { + /** + * Returns a new {@link HalFormsConfiguration} with the given + * + * @param + * @param type the + * @param property + * @param creator + * @return + */ + public HalFormsConfiguration withOptions(Class type, String property, + Function creator) { + + return new HalFormsConfiguration(halConfiguration, patterns, options.withOptions(type, property, creator), + objectMapperCustomizer); + } + + /** + * Returns the underlying {@link HalConfiguration}. + * + * @return will never be {@literal null}. + */ + HalConfiguration getHalConfiguration() { return halConfiguration; } + /** + * Returns the {@link HalFormsOptionsFactory} to look up {@link HalFormsOptions} from payload and property metadata. + * + * @return will never be {@literal null}. + */ + HalFormsOptionsFactory getOptionsFactory() { + return options; + } + /** * Returns the regular expression pattern that is registered for the given type. * diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsOptions.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsOptions.java new file mode 100644 index 00000000..af76823c --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsOptions.java @@ -0,0 +1,333 @@ +/* + * Copyright 2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.mediatype.hal.forms; + +import java.util.Arrays; +import java.util.Collection; + +import org.springframework.hateoas.Link; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Representation of HAL-FORMS {@code options} attribute. + * + * @author Oliver Drotbohm + * @see https://rwcbook.github.io/hal-forms/#options-element + * @since 1.3 + */ +@JsonInclude(Include.NON_EMPTY) +public interface HalFormsOptions { + + /** + * Creates a new {@link Inline} options representation listing the given values. + * + * @param values must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static Inline inline(Object... values) { + + Assert.notNull(values, "Values must not be null!"); + + return inline(Arrays.asList(values)); + } + + /** + * Creates a new {@link Inline} options representation listing the given collection of values. + * + * @param values must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static Inline inline(Collection values) { + + Assert.notNull(values, "Values must not be null!"); + + return new Inline(values, null, null, null, null); + } + + /** + * Creates a new {@link Remote} options representation using the given {@link Link}. + * + * @param link must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static Remote remote(Link link) { + + Assert.notNull(link, "Link must not be null!"); + + return new Remote(link, null, null, null, null); + } + + /** + * Creates a new {@link Remote} options representation using the given href. + * + * @param href must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static Remote remote(String href) { + + Assert.hasText(href, "Href must not by null or empty!"); + + return remote(Link.of(href)); + } + + /** + * The field to look up the prompt from. + * + * @return + */ + @Nullable + String getPromptField(); + + /** + * The field to use as the value to be sent. + * + * @return + */ + @Nullable + String getValueField(); + + /** + * Returns the minimum number of items to be selected. + * + * @return {@literal null}, 0 or a positive {@link Long}. + */ + @Nullable + Long getMinItems(); + + /** + * Returns the maximum number of items to be selected. + * + * @return {@literal null} or a positive {@link Long}. + */ + @Nullable + Long getMaxItems(); + + public static abstract class AbstractHalFormsOptions> + implements HalFormsOptions { + + private final @Nullable String promptField, valueField; + private final @Nullable Long minItems, maxItems; + + protected AbstractHalFormsOptions(@Nullable String promptRef, @Nullable String valueRef, @Nullable Long minItems, + @Nullable Long maxItems) { + + Assert.isTrue(minItems == null || minItems >= 0, "MinItems must be greater than or equal to 0!"); + + this.promptField = promptRef; + this.valueField = valueRef; + this.minItems = minItems; + this.maxItems = maxItems; + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.mediatype.hal.forms.HalFormsOptions#getPromptRef() + */ + @Nullable + @Override + public String getPromptField() { + return promptField; + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.mediatype.hal.forms.HalFormsOptions#getValueField() + */ + @Nullable + @Override + public String getValueField() { + return valueField; + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.mediatype.hal.forms.HalFormsOptions#getMinItems() + */ + @Nullable + @Override + public Long getMinItems() { + return minItems; + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.mediatype.hal.forms.HalFormsOptions#getMaxItems() + */ + @Nullable + @Override + public Long getMaxItems() { + return maxItems; + } + + /** + * Configures the given field to be used as prompt field. + * + * @param promptField must either be {@literal null} or actually have text. + * @return + */ + public T withPromptField(String promptField) { + + if (promptField != null && !StringUtils.hasText(promptField)) { + throw new IllegalArgumentException("Prompt field has to either be null or actually have text!"); + } + + return with(promptField, valueField, minItems, maxItems); + } + + /** + * Configures the given field to be used as value field. + * + * @param valueField must either be {@literal null} or actually have text. + * @return + */ + public T withValueField(String valueField) { + + if (valueField != null && !StringUtils.hasText(valueField)) { + throw new IllegalArgumentException("Value field has to either be null or actually have text!"); + } + + return with(promptField, valueField, minItems, maxItems); + } + + /** + * Configures the minimum number of items to be selected. + * + * @param minItems must be {@literal null} or greater than or equal to zero. + * @return + */ + public T withMinItems(Long minItems) { + + if (minItems != null && minItems < 0) { + throw new IllegalArgumentException("minItems has to be null or greater or equal to zero!"); + } + + return with(promptField, valueField, minItems, maxItems); + } + + /** + * Configures the maximum number of items to be selected. + * + * @param maxItems must be {@literal null} or greater than zero. + * @return + */ + public T withMaxItems(Long maxItems) { + + if (maxItems != null && maxItems <= 0) { + throw new IllegalArgumentException("maxItems has to be null or greater than zero!"); + } + + return with(promptField, valueField, minItems, maxItems); + } + + /** + * Create a new concrete {@link AbstractHalFormsOptions} + * + * @param promptRef + * @param valueRef + * @param minItems + * @param maxItems + * @return + */ + protected abstract T with(@Nullable String promptRef, @Nullable String valueRef, @Nullable Long minItems, + @Nullable Long maxItems); + } + + public static class Inline extends AbstractHalFormsOptions { + + private final Collection inline; + + /** + * @param values + * @param promptRef + * @param valueRef + */ + private Inline(Collection values, @Nullable String promptRef, @Nullable String valueRef, + @Nullable Long minItems, @Nullable Long maxItems) { + + super(promptRef, valueRef, minItems, maxItems); + + Assert.notNull(values, "Values must not be null!"); + + this.inline = values; + } + + /** + * Returns the inline values. + * + * @return + */ + @JsonProperty + public Collection getInline() { + return inline; + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.mediatype.hal.forms.HalFormsOptions.AbstractHalFormsOptions#with(java.lang.String, java.lang.String, java.lang.Long, java.lang.Long) + */ + @Override + protected Inline with(@Nullable String promptRef, @Nullable String valueRef, @Nullable Long minItems, + @Nullable Long maxItems) { + return new Inline(inline, promptRef, valueRef, minItems, maxItems); + } + } + + /** + * Representation of a remote options element. + * + * @author Oliver Drotbohm + */ + public static class Remote extends AbstractHalFormsOptions { + + private final Link link; + + private Remote(Link link, @Nullable String promptRef, @Nullable String valueRef, @Nullable Long minItems, + @Nullable Long maxItems) { + + super(promptRef, valueRef, minItems, maxItems); + + Assert.notNull(link, "Link must not be null!"); + + this.link = link; + } + + /** + * Returns the {@link Link} pointing to the resource returning option values. + * + * @return + */ + @JsonProperty + public Link getLink() { + return link; + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.mediatype.hal.forms.HalFormsOptions.Foo#withFoo(java.lang.String, java.lang.String) + */ + @Override + protected Remote with(@Nullable String promptRef, @Nullable String valueRef, @Nullable Long minItems, + @Nullable Long maxItems) { + return new Remote(link, promptRef, valueRef, minItems, maxItems); + } + } +} diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsOptionsFactory.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsOptionsFactory.java new file mode 100644 index 00000000..f5576a16 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsOptionsFactory.java @@ -0,0 +1,116 @@ +/* + * Copyright 2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.mediatype.hal.forms; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import org.springframework.hateoas.AffordanceModel.PayloadMetadata; +import org.springframework.hateoas.AffordanceModel.PropertyMetadata; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Factory implementation to register creator functions to eventually create {@link HalFormsOptions} from + * {@link PropertyMetadata} to decouple the registration (via {@link HalFormsConfiguration}) from the consumption during + * rendering. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +class HalFormsOptionsFactory { + + private final Map, Map>> options; + + /** + * Creates a new, empty {@link HalFormsOptionsFactory}. + */ + public HalFormsOptionsFactory() { + this.options = new HashMap<>(); + } + + /** + * Copy-constructor to keep {@link HalFormsConfiguration} immutable during registrations. + * + * @param options must not be {@literal null}. + */ + private HalFormsOptionsFactory(Map, Map>> options) { + this.options = options; + } + + /** + * Registers a {@link Function} to create a {@link HalFormsOptions} instance from the given {@link PropertyMetadata} + * to supply options for the given property of the given type. + * + * @param type must not be {@literal null}. + * @param property + * @param creator + * @return + * @see HalFormsOptions#inline(Object...) + * @see HalFormsOptions#remote(org.springframework.hateoas.Link) + */ + HalFormsOptionsFactory withOptions(Class type, String property, + Function creator) { + + Assert.notNull(type, "Type must not be null!"); + Assert.hasText(property, "Property must not be null or empty!"); + Assert.notNull(creator, "Creator function must not be null!"); + + Map, Map>> options = new HashMap<>(this.options); + + options.compute(type, (it, map) -> { + + if (map == null) { + map = new HashMap<>(); + } + + map.put(property, creator); + + return map; + }); + + return new HalFormsOptionsFactory(options); + } + + /** + * Returns the {@link HalFormsOptions} to be used for the property with the given {@link PayloadMetadata} and + * {@link PropertyMetadata}. + * + * @param payload must not be {@literal null}. + * @param property must not be {@literal null}. + * @return + */ + @Nullable + HalFormsOptions getOptions(PayloadMetadata payload, PropertyMetadata property) { + + Assert.notNull(payload, "Payload metadata must not be null!"); + Assert.notNull(property, "Property metadata must not be null!"); + + Class type = payload.getType(); + String name = property.getName(); + + Map> map = options.get(type); + + if (map == null) { + return null; + } + + Function function = map.get(name); + + return function == null ? null : function.apply(property); + } +} diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsPromptedValue.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsPromptedValue.java new file mode 100644 index 00000000..15653d6a --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsPromptedValue.java @@ -0,0 +1,118 @@ +/* + * Copyright 2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.mediatype.hal.forms; + +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.hateoas.mediatype.MessageSourceResolvableSerializer; +import org.springframework.util.Assert; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +/** + * A value object to describe prompted values for HAL-FORMS {@code options}' {@code inline} attribute or responses of + * resources pointed to by the {@code link} object. + * + * @author Oliver Drotbohm + * @see https://rwcbook.github.io/hal-forms/#options-element + * @since 1.3 + */ +public class HalFormsPromptedValue { + + private final Object prompt; + private final Object value; + + /** + * Creates a new {@link HalFormsPromptedValue} for the given prompt and value. + * + * @param prompt must not be {@literal null}. + * @param value must not be {@literal null}. + */ + private HalFormsPromptedValue(Object prompt, Object value) { + + Assert.notNull(prompt, "Prompt must not be null!"); + Assert.notNull(value, "Value must not be null!"); + + this.prompt = prompt; + this.value = value; + } + + /** + * Creates a new {@link HalFormsPromptedValue} with the given plain prompt and value. + * + * @param prompt must not be {@literal null} or empty. + * @param value + * @return + */ + public static HalFormsPromptedValue of(String prompt, Object value) { + + Assert.hasText(prompt, "Prompt must not be null or empty!"); + Assert.notNull(value, "Value must not be null!"); + + return new HalFormsPromptedValue(prompt, value); + } + + /** + * Creates a new {@link HalFormsPromptedValue} with the given prompt key to be used for i18nization and value. + * + * @param promptKey must not be {@literal null} or empty. + * @param value + * @return + */ + public static HalFormsPromptedValue ofI18ned(String promptKey, Object value) { + + Assert.hasText(promptKey, "Prompt key must not be null or empty!"); + Assert.notNull(value, "Value must not be null!"); + + return new HalFormsPromptedValue(new I18nizedPrompt(promptKey, value), value); + } + + /** + * Returns the prompt to be used. Can be a pre-resolved {@link String} or a value to be resolved into a String during + * serialization. + * + * @return will never be {@literal null}. + */ + @JsonProperty + public Object getPrompt() { + return prompt; + } + + /** + * Returns the value. + * + * @return will never be {@literal null}. + */ + @JsonProperty + public Object getValue() { + return value; + } + + /** + * Wrapper for a prompt to be i18ned via a {@link MessageSourceResolvableSerializer} during serialization. + * + * @author Oliver Drotbohm + */ + @JsonSerialize(using = MessageSourceResolvableSerializer.class) + private static class I18nizedPrompt extends DefaultMessageSourceResolvable { + + private static final long serialVersionUID = 7262804826421266153L; + + I18nizedPrompt(String promptKey, Object value) { + super(new String[] { promptKey }, new Object[] { value }, promptKey); + } + } +} 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 b9c35f96..38139dd8 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 @@ -43,6 +43,7 @@ final class HalFormsProperty implements Named { private final @JsonInclude(Include.NON_DEFAULT) boolean readOnly, required; private final @Nullable Long min, max, minLength, maxLength; private final @Nullable HtmlInputType type; + private final @Nullable HalFormsOptions options; HalFormsProperty() { @@ -60,11 +61,13 @@ final class HalFormsProperty implements Named { this.minLength = null; this.maxLength = null; this.type = null; + this.options = null; } private HalFormsProperty(String name, boolean readOnly, String value, String prompt, String regex, boolean templated, boolean required, boolean multi, String placeholder, @Nullable Long min, @Nullable Long max, - @Nullable Long minLength, @Nullable Long maxLength, @Nullable HtmlInputType type) { + @Nullable Long minLength, @Nullable Long maxLength, @Nullable HtmlInputType type, + @Nullable HalFormsOptions options) { Assert.notNull(name, "name must not be null!"); @@ -82,6 +85,7 @@ final class HalFormsProperty implements Named { this.minLength = minLength; this.maxLength = maxLength; this.type = type; + this.options = options; } /** @@ -106,7 +110,7 @@ final class HalFormsProperty implements Named { return this.name == name ? this : new HalFormsProperty(name, this.readOnly, this.value, this.prompt, this.regex, this.templated, this.required, - this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type); + this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type, this.options); } /** @@ -119,7 +123,7 @@ final class HalFormsProperty implements Named { return this.readOnly == readOnly ? this : new HalFormsProperty(this.name, readOnly, this.value, this.prompt, this.regex, this.templated, this.required, - this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type); + this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type, this.options); } /** @@ -132,7 +136,7 @@ final class HalFormsProperty implements Named { return this.value == value ? this : new HalFormsProperty(this.name, this.readOnly, value, this.prompt, this.regex, this.templated, this.required, - this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type); + this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type, this.options); } /** @@ -145,7 +149,7 @@ final class HalFormsProperty implements Named { return this.prompt == prompt ? this : new HalFormsProperty(this.name, this.readOnly, this.value, prompt, this.regex, this.templated, this.required, - this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type); + this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type, this.options); } /** @@ -158,7 +162,7 @@ final class HalFormsProperty implements Named { return this.regex == regex ? this : new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, regex, this.templated, this.required, - this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type); + this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type, this.options); } /** @@ -181,7 +185,7 @@ final class HalFormsProperty implements Named { return this.templated == templated ? this : new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, templated, this.required, - this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type); + this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type, this.options); } /** @@ -194,7 +198,7 @@ final class HalFormsProperty implements Named { return this.required == required ? this : new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated, required, - this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type); + this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type, this.options); } /** @@ -207,7 +211,8 @@ final class HalFormsProperty implements Named { return this.multi == multi ? this : new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated, - this.required, multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type); + this.required, multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type, + this.options); } /** @@ -220,72 +225,93 @@ final class HalFormsProperty implements Named { return this.placeholder == placeholder ? this : new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated, - this.required, this.multi, placeholder, this.min, this.max, this.minLength, this.maxLength, this.type); + this.required, this.multi, placeholder, this.min, this.max, this.minLength, this.maxLength, this.type, + this.options); } /** * Create a new {@link HalFormsProperty} by copying attributes and replacing {@literal min}. * - * @param min - * @return + * @param min can be {@literal null} + * @return will never be {@literal null}. */ HalFormsProperty withMin(@Nullable Long min) { return Objects.equals(this.min, min) ? this : new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated, - this.required, this.multi, this.placeholder, min, this.max, this.minLength, this.maxLength, this.type); + this.required, this.multi, this.placeholder, min, this.max, this.minLength, this.maxLength, this.type, + this.options); } /** * Create a new {@link HalFormsProperty} by copying attributes and replacing {@literal max}. * - * @param max - * @return + * @param max can be {@literal null} + * @return will never be {@literal null}. */ HalFormsProperty withMax(@Nullable Long max) { return Objects.equals(this.max, max) ? this : new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated, - this.required, this.multi, this.placeholder, this.min, max, this.minLength, this.maxLength, this.type); + this.required, this.multi, this.placeholder, this.min, max, this.minLength, this.maxLength, this.type, + this.options); } /** * Create a new {@link HalFormsProperty} by copying attributes and replacing {@literal minLength}. * - * @param minLength - * @return + * @param minLength can be {@literal null} + * @return will never be {@literal null}. */ HalFormsProperty withMinLength(@Nullable Long minLength) { return Objects.equals(this.minLength, minLength) ? this : new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated, - this.required, this.multi, this.placeholder, this.min, this.max, minLength, this.maxLength, this.type); + this.required, this.multi, this.placeholder, this.min, this.max, minLength, this.maxLength, this.type, + this.options); } /** * Create a new {@link HalFormsProperty} by copying attributes and replacing {@literal maxLength}. * - * @param maxLength - * @return + * @param maxLength can be {@literal null}. + * @return will never be {@literal null}. */ HalFormsProperty withMaxLength(@Nullable Long maxLength) { return Objects.equals(this.maxLength, maxLength) ? this : new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated, - this.required, this.multi, this.placeholder, this.min, this.max, this.minLength, maxLength, this.type); + this.required, this.multi, this.placeholder, this.min, this.max, this.minLength, maxLength, this.type, + this.options); } /** * Create a new {@link HalFormsProperty} by copying attributes and replacing {@literal type}. * - * @param type - * @return + * @param type can be {@literal null} + * @return will never be {@literal null}. */ HalFormsProperty withType(@Nullable HtmlInputType type) { return Objects.equals(this.type, type) ? this : new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated, - this.required, this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, type); + this.required, this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, type, + this.options); + } + + /** + * Creates a new {@link HalFormsProperty} by copying attributes and replacing {@literal options}. + * + * @param options can be {@literal null}. + * @return will never be {@literal null}. + * @since 1.3 + */ + HalFormsProperty withOptions(@Nullable HalFormsOptions options) { + + return Objects.equals(this.options, options) ? this + : new HalFormsProperty(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated, + this.required, this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type, + options); } /* @@ -388,6 +414,15 @@ final class HalFormsProperty implements Named { return type; } + /** + * @return the suggest + */ + @Nullable + @JsonProperty + HalFormsOptions getOptions() { + return options; + } + /* * (non-Javadoc) * @see java.lang.Object#equals(java.lang.Object) @@ -411,7 +446,9 @@ final class HalFormsProperty implements Named { && Objects.equals(this.value, that.value) // && Objects.equals(this.prompt, that.prompt) // && Objects.equals(this.regex, that.regex) // - && Objects.equals(this.placeholder, that.placeholder); + && Objects.equals(this.placeholder, that.placeholder) // + && Objects.equals(this.type, that.type) // + && Objects.equals(this.options, that.options); } /* @@ -422,7 +459,7 @@ final class HalFormsProperty implements Named { public int hashCode() { return Objects.hash(this.name, this.readOnly, this.value, this.prompt, this.regex, this.templated, this.required, - this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength); + this.multi, this.placeholder, this.min, this.max, this.minLength, this.maxLength, this.type, this.options); } /* @@ -445,6 +482,8 @@ final class HalFormsProperty implements Named { + ", max=" + this.max // + ", minLength=" + this.minLength // + ", maxLength=" + this.maxLength // + + ", type=" + this.type // + + ", options=" + this.options // + ")"; } } diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsPropertyFactory.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsPropertyFactory.java index f59a807d..30905e8f 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsPropertyFactory.java +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsPropertyFactory.java @@ -80,6 +80,8 @@ class HalFormsPropertyFactory { return Collections.emptyList(); } + HalFormsOptionsFactory options = configuration.getOptionsFactory(); + return model.createProperties((payload, metadata) -> { String inputTypeSource = metadata.getInputType(); @@ -94,7 +96,8 @@ class HalFormsPropertyFactory { .withMinLength(metadata.getMinLength()) .withMaxLength(metadata.getMaxLength()) .withRegex(lookupRegex(metadata)) // - .withType(inputType); + .withType(inputType) // + .withOptions(options.getOptions(payload, metadata)); Function factory = I18nedPropertyMetadata.factory(payload, property); diff --git a/src/test/java/org/springframework/hateoas/mediatype/AffordancesUnitTests.java b/src/test/java/org/springframework/hateoas/mediatype/AffordancesUnitTests.java index 4eac4fc7..53584216 100644 --- a/src/test/java/org/springframework/hateoas/mediatype/AffordancesUnitTests.java +++ b/src/test/java/org/springframework/hateoas/mediatype/AffordancesUnitTests.java @@ -23,7 +23,6 @@ import java.util.stream.Stream; import org.assertj.core.api.AbstractAssert; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; -import org.springframework.core.ResolvableType; import org.springframework.hateoas.Affordance; import org.springframework.hateoas.AffordanceModel; import org.springframework.hateoas.AffordanceModel.PayloadMetadata; @@ -114,7 +113,7 @@ public class AffordancesUnitTests { public PayloadMetadataAssert isBackedBy(Class type) { Assertions.assertThat(actual).isInstanceOfSatisfying(TypeBasedPayloadMetadata.class, it -> { - Assertions.assertThat(it.getType()).isEqualTo(ResolvableType.forClass(type)); + Assertions.assertThat(it.getType()).isEqualTo(type); }); return this; 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 fb815371..981cfd95 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 @@ -19,6 +19,8 @@ import static org.assertj.core.api.Assertions.*; import lombok.Getter; +import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -155,6 +157,31 @@ class HalFormsTemplateBuilderUnitTest { assertThat(templates.get("default").getContentType()).isEqualTo(mediaType.toString()); } + @Test // #1483 + void rendersRegisteredSuggest() { + + List values = Arrays.asList("1234123412341234", "4321432143214321"); + + HalFormsConfiguration configuration = new HalFormsConfiguration() + .withOptions(PatternExample.class, "number", metadata -> HalFormsOptions.inline(values)); + + RepresentationModel models = new RepresentationModel<>( + Affordances.of(Link.of("/example", LinkRelation.of("create"))) + .afford(HttpMethod.POST) + .withInput(PatternExample.class) + .toLink()); + + Map templates = new HalFormsTemplateBuilder(configuration, MessageResolver.DEFAULTS_ONLY) + .findTemplates(models); + + assertThat(templates.get("default").getPropertyByName("number")) + .hasValueSatisfying(it -> { + assertThat(it.getOptions()).isNotNull() + .isInstanceOfSatisfying(HalFormsOptions.Inline.class, + inline -> assertThat(inline.getInline()).isEqualTo(values)); + }); + } + @Getter static class PatternExample extends RepresentationModel { 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 93efca74..a5bea528 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 @@ -62,12 +62,15 @@ import org.springframework.hateoas.mediatype.hal.HalTestUtils; import org.springframework.hateoas.mediatype.hal.Jackson2HalModule; import org.springframework.hateoas.mediatype.hal.SimpleAnnotatedPojo; import org.springframework.hateoas.mediatype.hal.SimplePojo; +import org.springframework.hateoas.mediatype.hal.forms.HalFormsOptions.Inline; +import org.springframework.hateoas.mediatype.hal.forms.HalFormsOptions.Remote; import org.springframework.hateoas.server.LinkRelationProvider; 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.EmployeeResource; import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; import org.springframework.lang.Nullable; import com.fasterxml.jackson.annotation.JsonAutoDetect; @@ -75,6 +78,7 @@ import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; import com.fasterxml.jackson.annotation.JsonUnwrapped; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.jayway.jsonpath.DocumentContext; import com.jayway.jsonpath.JsonPath; import com.jayway.jsonpath.PathNotFoundException; @@ -558,6 +562,34 @@ class Jackson2HalFormsIntegrationTest { }).doesNotThrowAnyException(); } + @Test // #1483 + void rendersPromptedOptionsValues() throws Exception { + + Inline inline = HalFormsOptions.inline(HalFormsPromptedValue.ofI18ned("some.prompt", "myValue")); + + StaticMessageSource source = new StaticMessageSource(); + source.addMessage("some.prompt", Locale.US, "My Prompt"); + + ContextualMapper mapper = getCuriedObjectMapper(CurieProvider.NONE, source); + + assertThat(JsonPath.parse(mapper.writeObject(inline)).read("$.inline[0].prompt", String.class)) + .isEqualTo("My Prompt"); + } + + @Test // #1483 + void rendersRemoteOptions() { + + Link link = Link.of("/foo{?bar}").withType(MediaType.APPLICATION_JSON_VALUE); + + Remote remote = HalFormsOptions.remote(link); + + DocumentContext result = JsonPath.parse(getCuriedObjectMapper().writeObject(remote)); + + assertThat(result.read("$.link.href", String.class)).isEqualTo("/foo{?bar}"); + assertThat(result.read("$.link.type", String.class)).isEqualTo(MediaType.APPLICATION_JSON_VALUE); + assertThat(result.read("$.link.templated", boolean.class)).isTrue(); + } + private void assertThatPathDoesNotExist(Object toMarshall, String path) throws Exception { String json = getCuriedObjectMapper().writeObject(toMarshall);