#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
This commit is contained in:
Oliver Drotbohm
2021-01-27 19:12:56 +01:00
parent 5592d29f68
commit 94a50786f4
15 changed files with 823 additions and 53 deletions

View File

@@ -207,6 +207,11 @@ public abstract class AffordanceModel {
default Optional<PropertyMetadata> 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.

View File

@@ -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());
}
/*

View File

@@ -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<MessageSourceResolvable> {
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));
}
}

View File

@@ -137,7 +137,7 @@ public class PropertyUtils {
return Object.class.equals(resolved) //
? InputPayloadMetadata.NONE //
: new TypeBasedPayloadMetadata(domainType, lookupExposedProperties(resolved));
: new TypeBasedPayloadMetadata(resolved, lookupExposedProperties(resolved));
});
}

View File

@@ -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<String, PropertyMetadata> properties;
private final List<MediaType> mediaTypes;
TypeBasedPayloadMetadata(ResolvableType type, Stream<PropertyMetadata> properties) {
TypeBasedPayloadMetadata(Class<?> type, Stream<PropertyMetadata> properties) {
this(type, new TreeMap<>(
properties.collect(Collectors.toMap(PropertyMetadata::getName, Function.identity()))), Collections.emptyList());
}
TypeBasedPayloadMetadata(ResolvableType type, SortedMap<String, PropertyMetadata> properties,
TypeBasedPayloadMetadata(Class<?> type, SortedMap<String, PropertyMetadata> properties,
List<MediaType> mediaTypes) {
Assert.notNull(type, "Type must not be null!");
@@ -86,13 +85,10 @@ class TypeBasedPayloadMetadata implements InputPayloadMetadata {
*/
@Override
public List<String> getI18nCodes() {
Class<?> type = this.type.resolve(Object.class);
return Arrays.asList(type.getName(), type.getSimpleName());
}
ResolvableType getType() {
public Class<?> getType() {
return this.type;
}

View File

@@ -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));
}
}

View File

@@ -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<Class<?>, String> patterns;
private final Consumer<ObjectMapper> 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<Class<?>, String> patterns,
@Nullable Consumer<ObjectMapper> objectMapperCustomizer) {
HalFormsOptionsFactory options, @Nullable Consumer<ObjectMapper> 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<Class<?>, 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 <T>
* @param type the
* @param property
* @param creator
* @return
*/
public <T> HalFormsConfiguration withOptions(Class<T> type, String property,
Function<PropertyMetadata, HalFormsOptions> 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.
*

View File

@@ -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<Object> 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<T extends AbstractHalFormsOptions<T>>
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<Inline> {
private final Collection<Object> inline;
/**
* @param values
* @param promptRef
* @param valueRef
*/
private Inline(Collection<Object> 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<Object> 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<Remote> {
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);
}
}
}

View File

@@ -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<Class<?>, Map<String, Function<PropertyMetadata, HalFormsOptions>>> 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<Class<?>, Map<String, Function<PropertyMetadata, HalFormsOptions>>> 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<PropertyMetadata, HalFormsOptions> 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<Class<?>, Map<String, Function<PropertyMetadata, HalFormsOptions>>> 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<String, Function<PropertyMetadata, HalFormsOptions>> map = options.get(type);
if (map == null) {
return null;
}
Function<PropertyMetadata, HalFormsOptions> function = map.get(name);
return function == null ? null : function.apply(property);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 //
+ ")";
}
}

View File

@@ -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<String, I18nedPropertyMetadata> factory = I18nedPropertyMetadata.factory(payload, property);

View File

@@ -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;

View File

@@ -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<Object> 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<String, HalFormsTemplate> 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<PatternExample> {

View File

@@ -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);