#1018 - HAL Forms properties customizable via annotations on representation model classes.
We now consider additional Jackson and JSR-303 annotations on representation models to enrich the HAL Forms template properties with additional information: - @NotNull on a property flips its `required` flag to `true`. - The "regexp" attribute of @Pattern on a property is forwarded into the "regex" field. - @JsonProperty(Access.READ_ONLY) on a property is translated into the "readOnly" flag. The default for the "required" flag have been changed to false even for PUT and POST as whether they're required or not is completely determined by the model, not the HTTP method. All of this is backed by significant refactoring in the way that Affordance instances are build internally. The new API is centered around the Affordances type in the mediatype package. The methods on Link to create the Affordance` from its details have been removed and replaced by builder style APIs on Affordance. AffordanceModelFactory has been moved to the mediatype as well. PropertyUtils has been significantly revamped to expose a PayloadMetadata/PropertyMetadata model to abstract the individual traits of a property. This allows to optionally plug in the support for JSR-303 annotations. AffordanceModelFactory has been refactored to rather work with those instead of ResolvableType.
This commit is contained in:
@@ -19,16 +19,11 @@ import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.core.io.support.SpringFactoriesLoader;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* Hold the {@link AffordanceModel}s for all supported media types.
|
||||
@@ -37,37 +32,13 @@ import org.springframework.util.Assert;
|
||||
* @author Oliver Gierke
|
||||
*/
|
||||
@Value
|
||||
public class Affordance {
|
||||
|
||||
private static List<AffordanceModelFactory> factories = SpringFactoriesLoader
|
||||
.loadFactories(AffordanceModelFactory.class, Affordance.class.getClassLoader());
|
||||
public class Affordance implements Iterable<AffordanceModel> {
|
||||
|
||||
/**
|
||||
* Collection of {@link AffordanceModel}s related to this affordance.
|
||||
*/
|
||||
private final @Getter(AccessLevel.PACKAGE) Map<MediaType, AffordanceModel> affordanceModels = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Creates a new {@link Affordance}.
|
||||
*
|
||||
* @param name
|
||||
* @param link
|
||||
* @param httpMethod
|
||||
* @param inputType
|
||||
* @param queryMethodParameters
|
||||
* @param outputType
|
||||
*/
|
||||
public Affordance(String name, Link link, HttpMethod httpMethod, ResolvableType inputType,
|
||||
List<QueryParameter> queryMethodParameters, ResolvableType outputType) {
|
||||
|
||||
Assert.notNull(httpMethod, "httpMethod must not be null!");
|
||||
Assert.notNull(queryMethodParameters, "queryMethodParameters must not be null!");
|
||||
|
||||
for (AffordanceModelFactory factory : factories) {
|
||||
this.affordanceModels.put(factory.getMediaType(),
|
||||
factory.getAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType));
|
||||
}
|
||||
}
|
||||
@Getter(AccessLevel.PACKAGE) //
|
||||
private final Map<MediaType, AffordanceModel> models;
|
||||
|
||||
/**
|
||||
* Look up the {@link AffordanceModel} for the requested {@link MediaType}.
|
||||
@@ -78,6 +49,15 @@ public class Affordance {
|
||||
@Nullable
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T extends AffordanceModel> T getAffordanceModel(MediaType mediaType) {
|
||||
return (T) this.affordanceModels.get(mediaType);
|
||||
return (T) this.models.get(mediaType);
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see java.lang.Iterable#iterator()
|
||||
*/
|
||||
@Override
|
||||
public Iterator<AffordanceModel> iterator() {
|
||||
return models.values().iterator();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,14 @@ package org.springframework.hateoas;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.http.HttpMethod;
|
||||
@@ -54,7 +60,7 @@ public abstract class AffordanceModel {
|
||||
/**
|
||||
* Domain type used to create a new resource.
|
||||
*/
|
||||
private ResolvableType inputType;
|
||||
private InputPayloadMetadata input;
|
||||
|
||||
/**
|
||||
* Collection of {@link QueryParameter}s to interrogate a resource.
|
||||
@@ -64,7 +70,7 @@ public abstract class AffordanceModel {
|
||||
/**
|
||||
* Response body domain type.
|
||||
*/
|
||||
private ResolvableType outputType;
|
||||
private PayloadMetadata output;
|
||||
|
||||
/**
|
||||
* Expand the {@link Link} into an {@literal href} with no parameters.
|
||||
@@ -100,4 +106,209 @@ public abstract class AffordanceModel {
|
||||
|
||||
return getURI().equals(link.expand().getHref());
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata about payloads.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
public interface PayloadMetadata {
|
||||
|
||||
public static PayloadMetadata NONE = NoPayloadMetadata.INSTANCE;
|
||||
|
||||
/**
|
||||
* Returns all properties contained in a payload.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
Stream<PropertyMetadata> stream();
|
||||
|
||||
default Optional<PropertyMetadata> getPropertyMetadata(String name) {
|
||||
return stream().filter(it -> it.hasName(name)).findFirst();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload metadata for incoming requests.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
public interface InputPayloadMetadata extends PayloadMetadata {
|
||||
|
||||
static InputPayloadMetadata NONE = from(PayloadMetadata.NONE);
|
||||
|
||||
static InputPayloadMetadata from(PayloadMetadata metadata) {
|
||||
|
||||
return InputPayloadMetadata.class.isInstance(metadata) //
|
||||
? InputPayloadMetadata.class.cast(metadata)
|
||||
: DelegatingInputPayloadMetadata.of(metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the {@link InputPayloadMetadata} to the given target.
|
||||
*
|
||||
* @param <T>
|
||||
* @param target
|
||||
* @return
|
||||
*/
|
||||
<T extends PropertyMetadataConfigured<T> & Named> T applyTo(T target);
|
||||
|
||||
<T extends Named> T customize(T target, Function<PropertyMetadata, T> customizer);
|
||||
|
||||
/**
|
||||
* Returns the I18n codes to be used to resolve a name for the payload metadata.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
List<String> getI18nCodes();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link InputPayloadMetadata} to delegate to a target {@link PayloadMetadata} not applying any customizations.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
@ToString
|
||||
@EqualsAndHashCode
|
||||
@RequiredArgsConstructor(staticName = "of")
|
||||
private static class DelegatingInputPayloadMetadata implements InputPayloadMetadata {
|
||||
|
||||
private final PayloadMetadata metadata;
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.AffordanceModel.PayloadMetadata#stream()
|
||||
*/
|
||||
@Override
|
||||
public Stream<PropertyMetadata> stream() {
|
||||
return metadata.stream();
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.AffordanceModel.InputPayloadMetadata#customize(org.springframework.hateoas.AffordanceModel.PropertyMetadataConfigured)
|
||||
*/
|
||||
@Override
|
||||
public <T extends PropertyMetadataConfigured<T> & Named> T applyTo(T target) {
|
||||
return target;
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.AffordanceModel.InputPayloadMetadata#customize(org.springframework.hateoas.AffordanceModel.Named, java.util.function.Function)
|
||||
*/
|
||||
@Override
|
||||
public <T extends Named> T customize(T target, Function<PropertyMetadata, T> customizer) {
|
||||
return target;
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.AffordanceModel.InputPayloadMetadata#getI18nCodes()
|
||||
*/
|
||||
@Override
|
||||
public List<String> getI18nCodes() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata about the property model of a representation.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
public interface PropertyMetadata {
|
||||
|
||||
/**
|
||||
* The name of the property.
|
||||
*
|
||||
* @return will never be {@literal null} or empty.
|
||||
*/
|
||||
String getName();
|
||||
|
||||
/**
|
||||
* Whether the property has the given name.
|
||||
*
|
||||
* @param name must not be {@literal null} or empty.
|
||||
* @return
|
||||
*/
|
||||
default boolean hasName(String name) {
|
||||
|
||||
Assert.hasText(name, "Name must not be null or empty!");
|
||||
|
||||
return getName().equals(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the property is required to be submitted or always present in the representation returned.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
boolean isRequired();
|
||||
|
||||
/**
|
||||
* Whether the property is read only, i.e. must not be manipulated in requests modifying state.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
boolean isReadOnly();
|
||||
|
||||
/**
|
||||
* Returns the (regular expression) pattern the property has to adhere to.
|
||||
*
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
Optional<String> getPattern();
|
||||
|
||||
/**
|
||||
* Return the type of the property. If no type can be determined, return {@link Object}.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
ResolvableType getType();
|
||||
}
|
||||
|
||||
/**
|
||||
* SPI for a type that can get {@link PropertyMetadata} applied.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
public interface PropertyMetadataConfigured<T> {
|
||||
|
||||
/**
|
||||
* Applies the given {@link PropertyMetadata}.
|
||||
*
|
||||
* @param metadata will never be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
T apply(PropertyMetadata metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* A named component.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
public interface Named {
|
||||
String getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty {@link PayloadMetadata}.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
private static enum NoPayloadMetadata implements PayloadMetadata {
|
||||
|
||||
INSTANCE;
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.AffordanceModel.PayloadMetadata#stream()
|
||||
*/
|
||||
@Override
|
||||
public Stream<PropertyMetadata> stream() {
|
||||
return Stream.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,6 @@ import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@@ -211,60 +209,6 @@ public class Link implements Serializable {
|
||||
return withAffordances(newAffordances);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method when chaining an existing {@link Link}.
|
||||
*
|
||||
* @param name
|
||||
* @param httpMethod
|
||||
* @param inputType
|
||||
* @param queryMethodParameters
|
||||
* @param outputType
|
||||
* @return
|
||||
*/
|
||||
public Link andAffordance(String name, HttpMethod httpMethod, ResolvableType inputType,
|
||||
List<QueryParameter> queryMethodParameters, ResolvableType outputType) {
|
||||
return andAffordance(new Affordance(name, this, httpMethod, inputType, queryMethodParameters, outputType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method when chaining an existing {@link Link}. Defaults the name of the affordance to verb + classname,
|
||||
* e.g. {@literal <httpMethod=HttpMethod.PUT, inputType=Employee>} produces {@literal <name=putEmployee>}.
|
||||
*
|
||||
* @param httpMethod
|
||||
* @param inputType
|
||||
* @param queryMethodParameters
|
||||
* @param outputType
|
||||
* @return
|
||||
*/
|
||||
public Link andAffordance(HttpMethod httpMethod, ResolvableType inputType, List<QueryParameter> queryMethodParameters,
|
||||
ResolvableType outputType) {
|
||||
|
||||
String name = httpMethod.toString().toLowerCase();
|
||||
|
||||
Class<?> resolvedInputType = inputType.resolve();
|
||||
if (resolvedInputType != null) {
|
||||
name += resolvedInputType.getSimpleName();
|
||||
}
|
||||
|
||||
return andAffordance(name, httpMethod, inputType, queryMethodParameters, outputType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method when chaining an existing {@link Link}. Defaults the name of the affordance to verb + classname,
|
||||
* e.g. {@literal <httpMethod=HttpMethod.PUT, inputType=Employee>} produces {@literal <name=putEmployee>}.
|
||||
*
|
||||
* @param httpMethod
|
||||
* @param inputType
|
||||
* @param queryMethodParameters
|
||||
* @param outputType
|
||||
* @return
|
||||
*/
|
||||
public Link andAffordance(HttpMethod httpMethod, Class<?> inputType, List<QueryParameter> queryMethodParameters,
|
||||
Class<?> outputType) {
|
||||
return andAffordance(httpMethod, ResolvableType.forClass(inputType), queryMethodParameters,
|
||||
ResolvableType.forClass(outputType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new {@link Link} with additional {@link Affordance}s.
|
||||
*
|
||||
|
||||
@@ -15,19 +15,82 @@
|
||||
*/
|
||||
package org.springframework.hateoas;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.Value;
|
||||
import lombok.experimental.Wither;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.core.annotation.MergedAnnotation;
|
||||
import org.springframework.core.annotation.MergedAnnotations;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
/**
|
||||
* Representation of a web request's query parameter (https://example.com?name=foo) => {"name", "foo", true}.
|
||||
*
|
||||
* @author Greg Turnquist
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
@Data
|
||||
@RequiredArgsConstructor
|
||||
@Value
|
||||
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public class QueryParameter {
|
||||
|
||||
private final String name;
|
||||
private final String value;
|
||||
private final @Nullable @Wither String value;
|
||||
private final boolean required;
|
||||
|
||||
/**
|
||||
* Creates a new {@link QueryParameter} from the given {@link MethodParameter}.
|
||||
*
|
||||
* @param parameter must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public static QueryParameter of(MethodParameter parameter) {
|
||||
|
||||
MergedAnnotation<RequestParam> annotation = MergedAnnotations //
|
||||
.from(parameter.getParameter()) //
|
||||
.get(RequestParam.class);
|
||||
|
||||
String name = annotation.isPresent() && annotation.hasNonDefaultValue("name") //
|
||||
? annotation.getString("name") //
|
||||
: parameter.getParameterName();
|
||||
|
||||
if (name == null || !StringUtils.hasText(name)) {
|
||||
throw new IllegalStateException(String.format("Couldn't determine parameter name for %s!", parameter));
|
||||
}
|
||||
|
||||
boolean required = annotation.isPresent() && annotation.hasNonDefaultValue("required") //
|
||||
? annotation.getBoolean("required") //
|
||||
: !Optional.class.equals(parameter.getParameterType()); //
|
||||
|
||||
return required ? required(name) : optional(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new required {@link QueryParameter} with the given name;
|
||||
*
|
||||
* @param name must not be {@literal null} or empty.
|
||||
* @return
|
||||
*/
|
||||
public static QueryParameter required(String name) {
|
||||
|
||||
Assert.hasText(name, "Name must not be null or empty!");
|
||||
|
||||
return new QueryParameter(name, null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new optional {@link QueryParameter} with the given name;
|
||||
*
|
||||
* @param name must not be {@literal null} or empty.
|
||||
* @return
|
||||
*/
|
||||
public static QueryParameter optional(String name) {
|
||||
return new QueryParameter(name, null, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ class HypermediaConfigurationImportSelector implements ImportSelector {
|
||||
|
||||
Map<String, Object> attributes = metadata.getAnnotationAttributes(EnableHypermediaSupport.class.getName());
|
||||
|
||||
Collection<MediaType> types = attributes == null ? Collections.emptyList()
|
||||
Collection<MediaType> types = attributes == null //
|
||||
? Collections.emptyList() //
|
||||
: Arrays.stream((HypermediaType[]) attributes.get("type")) //
|
||||
.flatMap(it -> it.getMediaTypes().stream()) //
|
||||
.collect(Collectors.toList());
|
||||
|
||||
@@ -13,11 +13,15 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.hateoas;
|
||||
package org.springframework.hateoas.mediatype;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.hateoas.AffordanceModel;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.QueryParameter;
|
||||
import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
|
||||
import org.springframework.hateoas.AffordanceModel.PayloadMetadata;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
@@ -45,6 +49,6 @@ public interface AffordanceModelFactory {
|
||||
* @param outputType
|
||||
* @return
|
||||
*/
|
||||
AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType,
|
||||
List<QueryParameter> queryMethodParameters, ResolvableType outputType);
|
||||
AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType,
|
||||
List<QueryParameter> queryMethodParameters, PayloadMetadata outputType);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2019 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 org.springframework.hateoas.Affordance;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.mediatype.Affordances.AffordanceBuilder;
|
||||
|
||||
/**
|
||||
* Operations commons to all builder APIs.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
* @see AffordanceBuilder
|
||||
*/
|
||||
public interface AffordanceOperations {
|
||||
|
||||
/**
|
||||
* Returns a {@link Link} equipped with the {@link Affordance} currently under construction.
|
||||
*
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
Link toLink();
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* Copyright 2019 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 static java.util.stream.Collectors.*;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.Wither;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.core.io.support.SpringFactoriesLoader;
|
||||
import org.springframework.hateoas.Affordance;
|
||||
import org.springframework.hateoas.AffordanceModel;
|
||||
import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
|
||||
import org.springframework.hateoas.AffordanceModel.PayloadMetadata;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.QueryParameter;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* Primary API to construct {@link Affordance} instances.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
* @see #afford(HttpMethod)
|
||||
*/
|
||||
@RequiredArgsConstructor(staticName = "of")
|
||||
public class Affordances implements AffordanceOperations {
|
||||
|
||||
private static List<AffordanceModelFactory> factories = SpringFactoriesLoader
|
||||
.loadFactories(AffordanceModelFactory.class, Affordance.class.getClassLoader());
|
||||
|
||||
private final Link link;
|
||||
|
||||
/**
|
||||
* Returns all {@link Affordance}s created.
|
||||
*
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public Stream<Affordance> stream() {
|
||||
return link.getAffordances().stream();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link AffordanceBuilder} for the given HTTP method for further customization. See the wither-methods
|
||||
* for details.
|
||||
*
|
||||
* @param httpMethod must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public AffordanceBuilder afford(HttpMethod httpMethod) {
|
||||
|
||||
Assert.notNull(httpMethod, "HTTP method must not be null!");
|
||||
|
||||
return new AffordanceBuilder(this, httpMethod, link, InputPayloadMetadata.NONE, PayloadMetadata.NONE,
|
||||
Collections.emptyList(), null);
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.TerminalOperations#build()
|
||||
*/
|
||||
public Link toLink() {
|
||||
return link;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder API for {@link Affordance} instances.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public static class AffordanceBuilder implements AffordanceOperations {
|
||||
|
||||
private final Affordances context;
|
||||
|
||||
private final HttpMethod method;
|
||||
private final @Wither Link target;
|
||||
private final InputPayloadMetadata inputMetdata;
|
||||
private final PayloadMetadata outputMetadata;
|
||||
|
||||
private List<QueryParameter> parameters = Collections.emptyList();
|
||||
private @Nullable @Wither String name;
|
||||
|
||||
/**
|
||||
* Registers the given type as input and output model for the affordance.
|
||||
*
|
||||
* @param type must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public AffordanceBuilder withInputAndOutput(Class<?> type) {
|
||||
return withInput(type).withOutput(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given {@link ResolvableType} as input and output model for the affordance.
|
||||
*
|
||||
* @param type must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public AffordanceBuilder withInputAndOutput(ResolvableType type) {
|
||||
return withInput(type).withOutput(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given {@link PayloadMetadata} as input and output model.
|
||||
*
|
||||
* @param metadata must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public AffordanceBuilder withInputAndOutput(PayloadMetadata metadata) {
|
||||
return withInput(metadata).withOutput(metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given type as input model for the affordance.
|
||||
*
|
||||
* @param type must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public AffordanceBuilder withInput(Class<?> type) {
|
||||
|
||||
Assert.notNull(type, "Type must not be null!");
|
||||
|
||||
return withInput(ResolvableType.forClass(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given {@link ResolvableType} as input model for the affordance.
|
||||
*
|
||||
* @param type must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public AffordanceBuilder withInput(ResolvableType type) {
|
||||
|
||||
Assert.notNull(type, "Type must not be null!");
|
||||
|
||||
return withInput(PropertyUtils.getExposedProperties(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given {@link PayloadMetadata} as input model.
|
||||
*
|
||||
* @param metadata must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public AffordanceBuilder withInput(PayloadMetadata metadata) {
|
||||
|
||||
InputPayloadMetadata inputMetadata = InputPayloadMetadata.from(metadata);
|
||||
|
||||
return new AffordanceBuilder(context, method, target, inputMetadata, outputMetadata, parameters, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given type as the output model.
|
||||
*
|
||||
* @param type must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public AffordanceBuilder withOutput(Class<?> type) {
|
||||
return withOutput(ResolvableType.forClass(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given {@link ResolvableType} as the output model.
|
||||
*
|
||||
* @param type must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public AffordanceBuilder withOutput(ResolvableType type) {
|
||||
return withOutput(PropertyUtils.getExposedProperties(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given {@link PayloadMetadata} as output model.
|
||||
*
|
||||
* @param metadata must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public AffordanceBuilder withOutput(PayloadMetadata metadata) {
|
||||
return new AffordanceBuilder(context, method, target, inputMetdata, metadata, parameters, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the current {@link QueryParameters} with the given ones.
|
||||
*
|
||||
* @param parameters must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public AffordanceBuilder withParameters(QueryParameter... parameters) {
|
||||
return withParameters(Arrays.asList(parameters));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the current {@link QueryParameters} with the given ones.
|
||||
*
|
||||
* @param parameters must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public AffordanceBuilder withParameters(List<QueryParameter> parameters) {
|
||||
return new AffordanceBuilder(context, method, target, inputMetdata, outputMetadata, parameters, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given {@link QueryParameter}s to the {@link Affordance} to build.
|
||||
*
|
||||
* @param parameters must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public AffordanceBuilder addParameters(QueryParameter... parameters) {
|
||||
|
||||
List<QueryParameter> newParameters = new ArrayList<>(this.parameters.size() + parameters.length);
|
||||
newParameters.addAll(this.parameters);
|
||||
newParameters.addAll(Arrays.asList(parameters));
|
||||
|
||||
return new AffordanceBuilder(context, method, target, inputMetdata, outputMetadata, newParameters, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Concludes the creation of the current {@link Affordance} to build and starts a new one.
|
||||
*
|
||||
* @param method must not be {@literal null}.
|
||||
* @return
|
||||
* @see #build()
|
||||
* @see #toLink()
|
||||
*/
|
||||
public AffordanceBuilder andAfford(HttpMethod method) {
|
||||
return build().afford(method);
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.AffordanceOperations#toLink()
|
||||
*/
|
||||
@Override
|
||||
public Link toLink() {
|
||||
return context.link.andAffordance(buildAffordance());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@link Affordance} currently under construction and returns in alongside the ones already contained in
|
||||
* the {@link Link} the buildup started from.
|
||||
*
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public Affordances build() {
|
||||
return Affordances.of(toLink());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an {@link Affordance} from the current state of the builder.
|
||||
*
|
||||
* @return must not be {@literal null}.
|
||||
*/
|
||||
private Affordance buildAffordance() {
|
||||
|
||||
return factories.stream() //
|
||||
.collect(collectingAndThen(toMap(AffordanceModelFactory::getMediaType, //
|
||||
it -> createModel(it, parameters == null ? Collections.emptyList() : parameters)), //
|
||||
Affordance::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link AffordanceModel} using the given {@link AffordanceModelFactory} and {@link QueryParameter}s.
|
||||
*
|
||||
* @param factory must not be {@literal null}.
|
||||
* @param parameters must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
private AffordanceModel createModel(AffordanceModelFactory factory, List<QueryParameter> parameters) {
|
||||
return factory.getAffordanceModel(getNameOrDefault(), target, method, inputMetdata, parameters, outputMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the explicitly configured name of the {@link Affordance} or calculates a default based on the
|
||||
* {@link HttpMethod} and type backing it.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
private String getNameOrDefault() {
|
||||
|
||||
if (name != null) {
|
||||
return name;
|
||||
}
|
||||
|
||||
String name = method.toString().toLowerCase();
|
||||
|
||||
ResolvableType 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,35 +15,46 @@
|
||||
*/
|
||||
package org.springframework.hateoas.mediatype;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.beans.FeatureDescriptor;
|
||||
import java.beans.PropertyDescriptor;
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.*;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.validation.constraints.Pattern;
|
||||
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.BeanWrapper;
|
||||
import org.springframework.beans.PropertyAccessorFactory;
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.core.annotation.MergedAnnotation;
|
||||
import org.springframework.core.annotation.MergedAnnotations;
|
||||
import org.springframework.core.convert.Property;
|
||||
import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
|
||||
import org.springframework.hateoas.AffordanceModel.PropertyMetadata;
|
||||
import org.springframework.hateoas.CollectionModel;
|
||||
import org.springframework.hateoas.EntityModel;
|
||||
import org.springframework.hateoas.support.WebStack;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ClassUtils;
|
||||
import org.springframework.util.ConcurrentReferenceHashMap;
|
||||
import org.springframework.util.ReflectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty.Access;
|
||||
|
||||
/**
|
||||
* @author Greg Turnquist
|
||||
@@ -51,70 +62,43 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
*/
|
||||
public class PropertyUtils {
|
||||
|
||||
private final static HashSet<String> FIELDS_TO_IGNORE = new HashSet<>();
|
||||
private static final Map<ResolvableType, ResolvableType> DOMAIN_TYPE_CACHE = new ConcurrentReferenceHashMap<>();
|
||||
private static final Map<ResolvableType, InputPayloadMetadata> METADATA_CACHE = new ConcurrentReferenceHashMap<>();
|
||||
private static final Set<String> FIELDS_TO_IGNORE = new HashSet<>(Arrays.asList("class", "links"));
|
||||
private static final boolean JSR_303_PRESENT = ClassUtils.isPresent("javax.validation.Valid",
|
||||
PropertyUtils.class.getClassLoader());
|
||||
private static final List<Class<?>> TYPES_TO_UNWRAP = new ArrayList<>(
|
||||
Arrays.asList(EntityModel.class, CollectionModel.class, HttpEntity.class));
|
||||
private static final ResolvableType OBJECT_TYPE = ResolvableType.forClass(Object.class);
|
||||
|
||||
static {
|
||||
FIELDS_TO_IGNORE.add("class");
|
||||
FIELDS_TO_IGNORE.add("links");
|
||||
if (ClassUtils.isPresent("org.reactivestreams.Publisher", PropertyUtils.class.getClassLoader())) {
|
||||
TYPES_TO_UNWRAP.addAll(ReactiveWrappers.getTypesToUnwrap());
|
||||
}
|
||||
}
|
||||
|
||||
public static Map<String, Object> findProperties(@Nullable Object object) {
|
||||
private static class ReactiveWrappers {
|
||||
|
||||
static List<Class<?>> getTypesToUnwrap() {
|
||||
return Arrays.asList(Publisher.class);
|
||||
}
|
||||
}
|
||||
|
||||
public static Map<String, Object> extractPropertyValues(@Nullable Object object) {
|
||||
|
||||
if (object == null) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
if (object.getClass().equals(EntityModel.class)) {
|
||||
return findProperties(((EntityModel<?>) object).getContent());
|
||||
if (EntityModel.class.isInstance(object)) {
|
||||
return extractPropertyValues(EntityModel.class.cast(object).getContent());
|
||||
}
|
||||
|
||||
return getPropertyDescriptors(object.getClass()) //
|
||||
.collect(HashMap::new, (hashMap, descriptor) -> {
|
||||
BeanWrapper wrapper = PropertyAccessorFactory.forBeanPropertyAccess(object);
|
||||
|
||||
try {
|
||||
|
||||
Method readMethod = descriptor.getReadMethod();
|
||||
ReflectionUtils.makeAccessible(readMethod);
|
||||
hashMap.put(descriptor.getName(), readMethod.invoke(object));
|
||||
|
||||
} catch (IllegalAccessException | InvocationTargetException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
}, HashMap::putAll);
|
||||
}
|
||||
|
||||
public static List<String> findPropertyNames(ResolvableType resolvableType) {
|
||||
|
||||
Class<?> type = resolvableType.getRawClass();
|
||||
|
||||
if (WebStack.WEBFLUX.isAvailable()) {
|
||||
if (Mono.class.equals(type) || Flux.class.equals(type)) {
|
||||
ResolvableType generic = resolvableType.getGeneric(0);
|
||||
return findPropertyNames(generic);
|
||||
}
|
||||
}
|
||||
|
||||
if (type == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
if (!type.equals(EntityModel.class)) {
|
||||
return findPropertyNames(type);
|
||||
}
|
||||
|
||||
Class<?> genericEntityModelParameter = resolvableType.resolveGeneric(0);
|
||||
|
||||
return genericEntityModelParameter == null //
|
||||
? Collections.emptyList() //
|
||||
: findPropertyNames(genericEntityModelParameter);
|
||||
}
|
||||
|
||||
public static List<String> findPropertyNames(Class<?> clazz) {
|
||||
|
||||
return getPropertyDescriptors(clazz) //
|
||||
.map(FeatureDescriptor::getName) //
|
||||
.collect(Collectors.toList());
|
||||
return getExposedProperties(object.getClass()).stream() //
|
||||
.map(PropertyMetadata::getName)
|
||||
.collect(HashMap::new, (map, name) -> map.put(name, wrapper.getPropertyValue(name)), HashMap::putAll);
|
||||
}
|
||||
|
||||
public static <T> T createObjectFromProperties(Class<T> clazz, Map<String, Object> properties) {
|
||||
@@ -140,18 +124,86 @@ public class PropertyUtils {
|
||||
return obj;
|
||||
}
|
||||
|
||||
public static InputPayloadMetadata getExposedProperties(@Nullable Class<?> type) {
|
||||
return getExposedProperties(type == null ? null : ResolvableType.forClass(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link InputPayloadMetadata} model for the given {@link ResolvableType}.
|
||||
*
|
||||
* @param type must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public static InputPayloadMetadata getExposedProperties(@Nullable ResolvableType type) {
|
||||
|
||||
if (type == null) {
|
||||
return InputPayloadMetadata.NONE;
|
||||
}
|
||||
|
||||
return METADATA_CACHE.computeIfAbsent(type, it -> {
|
||||
|
||||
ResolvableType domainType = unwrapDomainType(type);
|
||||
Class<?> resolved = domainType.resolve(Object.class);
|
||||
|
||||
return Object.class.equals(resolved) //
|
||||
? InputPayloadMetadata.NONE //
|
||||
: new TypeBasedPayloadMetadata(domainType, lookupExposedProperties(resolved));
|
||||
});
|
||||
}
|
||||
|
||||
private static ResolvableType unwrapDomainType(ResolvableType type) {
|
||||
|
||||
if (!type.hasGenerics()) {
|
||||
return type;
|
||||
}
|
||||
|
||||
if (type.hasUnresolvableGenerics()) {
|
||||
return replaceIfUnwrappable(type, () -> OBJECT_TYPE);
|
||||
}
|
||||
|
||||
return DOMAIN_TYPE_CACHE.computeIfAbsent(type,
|
||||
it -> replaceIfUnwrappable(it, () -> unwrapDomainType(it.getGeneric(0))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the given {@link ResolvableType} with the one produced by the given {@link Supplier} if the former is
|
||||
* assignable from one of the types to be unwrapped.
|
||||
*
|
||||
* @param type must not be {@literal null}.
|
||||
* @param mapper must not be {@literal null}.
|
||||
* @return
|
||||
* @see #TYPES_TO_UNWRAP
|
||||
*/
|
||||
private static ResolvableType replaceIfUnwrappable(ResolvableType type, Supplier<ResolvableType> mapper) {
|
||||
|
||||
Class<?> resolved = type.resolve(Object.class);
|
||||
|
||||
return TYPES_TO_UNWRAP.stream().anyMatch(it -> it.isAssignableFrom(resolved)) //
|
||||
? mapper.get() //
|
||||
: type;
|
||||
}
|
||||
|
||||
private static Stream<PropertyMetadata> lookupExposedProperties(@Nullable Class<?> type) {
|
||||
|
||||
return type == null //
|
||||
? Stream.empty() //
|
||||
: getPropertyDescriptors(type) //
|
||||
.map(it -> new AnnotatedProperty(new Property(type, it.getReadMethod(), it.getWriteMethod())))
|
||||
.map(it -> JSR_303_PRESENT ? new Jsr303AwarePropertyMetadata(it) : new DefaultPropertyMetadata(it));
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a {@link Class} and find all properties that are NOT to be ignored, and return them as a {@link Stream}.
|
||||
*
|
||||
* @param clazz
|
||||
* @param type
|
||||
* @return
|
||||
*/
|
||||
private static Stream<PropertyDescriptor> getPropertyDescriptors(Class<?> clazz) {
|
||||
private static Stream<PropertyDescriptor> getPropertyDescriptors(Class<?> type) {
|
||||
|
||||
return Arrays.stream(BeanUtils.getPropertyDescriptors(clazz))
|
||||
return Arrays.stream(BeanUtils.getPropertyDescriptors(type))
|
||||
.filter(descriptor -> !FIELDS_TO_IGNORE.contains(descriptor.getName()))
|
||||
.filter(descriptor -> !descriptorToBeIgnoredByJackson(clazz, descriptor))
|
||||
.filter(descriptor -> !toBeIgnoredByJackson(clazz, descriptor.getName()))
|
||||
.filter(descriptor -> !descriptorToBeIgnoredByJackson(type, descriptor))
|
||||
.filter(descriptor -> !toBeIgnoredByJackson(type, descriptor.getName()))
|
||||
.filter(descriptor -> !readerIsNotToBeIgnoredByJackson(descriptor));
|
||||
}
|
||||
|
||||
@@ -168,7 +220,7 @@ public class PropertyUtils {
|
||||
|
||||
return descriptorField == null //
|
||||
? false //
|
||||
: toBeIgnoredByJackson(AnnotationUtils.getAnnotations(descriptorField));
|
||||
: toBeIgnoredByJackson(MergedAnnotations.from(descriptorField));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,7 +230,7 @@ public class PropertyUtils {
|
||||
* @return
|
||||
*/
|
||||
private static boolean readerIsNotToBeIgnoredByJackson(PropertyDescriptor descriptor) {
|
||||
return toBeIgnoredByJackson(AnnotationUtils.getAnnotations(descriptor.getReadMethod()));
|
||||
return toBeIgnoredByJackson(MergedAnnotations.from(descriptor.getReadMethod()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,15 +239,12 @@ public class PropertyUtils {
|
||||
* @param annotations
|
||||
* @return
|
||||
*/
|
||||
private static boolean toBeIgnoredByJackson(@Nullable Annotation[] annotations) {
|
||||
private static boolean toBeIgnoredByJackson(MergedAnnotations annotations) {
|
||||
|
||||
return annotations == null //
|
||||
? false
|
||||
: Arrays.stream(annotations) //
|
||||
.filter(annotation -> annotation.annotationType().equals(JsonIgnore.class)) //
|
||||
.findFirst() //
|
||||
.map(annotation -> (Boolean) AnnotationUtils.getAnnotationAttributes(annotation).get("value")) //
|
||||
.orElse(false);
|
||||
return annotations.stream(JsonIgnore.class) //
|
||||
.findFirst() //
|
||||
.map(it -> it.getBoolean("value")) //
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,14 +256,261 @@ public class PropertyUtils {
|
||||
*/
|
||||
private static boolean toBeIgnoredByJackson(Class<?> clazz, String field) {
|
||||
|
||||
Annotation[] annotations = AnnotationUtils.getAnnotations(clazz);
|
||||
MergedAnnotations annotations = MergedAnnotations.from(clazz);
|
||||
|
||||
return annotations == null //
|
||||
? false //
|
||||
: Arrays.stream(annotations) //
|
||||
.filter(annotation -> annotation.annotationType().equals(JsonIgnoreProperties.class)) //
|
||||
.map(annotation -> (String[]) AnnotationUtils.getAnnotationAttributes(annotation).get("value")) //
|
||||
.flatMap(Arrays::stream) //
|
||||
.anyMatch(propertyName -> propertyName.equalsIgnoreCase(field));
|
||||
return annotations.stream(JsonIgnoreProperties.class) //
|
||||
.map(it -> it.getStringArray("value")) //
|
||||
.flatMap(Arrays::stream) //
|
||||
.anyMatch(it -> it.equalsIgnoreCase(field));
|
||||
}
|
||||
|
||||
/**
|
||||
* An abstraction of a {@link Property} in combination with an underlying field for the purpose of looking up
|
||||
* annotations on either the accessors or the field itself.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
private static class AnnotatedProperty {
|
||||
|
||||
private final Map<Class<?>, MergedAnnotation<?>> annotationCache = new ConcurrentReferenceHashMap<>();
|
||||
|
||||
private final Property property;
|
||||
private final ResolvableType type;
|
||||
private final List<MergedAnnotations> annotations;
|
||||
private final MergedAnnotations typeAnnotations;
|
||||
|
||||
/**
|
||||
* Creates a new {@link AnnotatedProperty} for the given {@link Property}.
|
||||
*
|
||||
* @param property must not be {@literal null}.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public AnnotatedProperty(Property property) {
|
||||
|
||||
Assert.notNull(property, "Property must not be null!");
|
||||
|
||||
this.property = property;
|
||||
|
||||
Field field = ReflectionUtils.findField(property.getObjectType(), property.getName());
|
||||
|
||||
this.type = firstNonEmpty( //
|
||||
() -> Optional.ofNullable(property.getReadMethod()).map(ResolvableType::forMethodReturnType), //
|
||||
() -> Optional.ofNullable(property.getWriteMethod()).map(it -> ResolvableType.forMethodParameter(it, 0)), //
|
||||
() -> Optional.ofNullable(field).map(ResolvableType::forField));
|
||||
|
||||
this.annotations = Stream.of(property.getReadMethod(), property.getWriteMethod(), field) //
|
||||
.filter(it -> it != null) //
|
||||
.map(MergedAnnotations::from) //
|
||||
.collect(Collectors.toList());
|
||||
|
||||
this.typeAnnotations = MergedAnnotations.from(this.type.resolve(Object.class));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> T firstNonEmpty(Supplier<Optional<T>>... suppliers) {
|
||||
|
||||
Assert.notNull(suppliers, "Suppliers must not be null!");
|
||||
|
||||
return Stream.of(suppliers) //
|
||||
.map(Supplier::get).flatMap(it -> it.map(Stream::of).orElseGet(Stream::empty)) //
|
||||
.findFirst() //
|
||||
.orElseThrow(() -> new IllegalStateException("Could not resolve value!"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the property.
|
||||
*
|
||||
* @return will never be {@literal null} or empty.
|
||||
*/
|
||||
public String getName() {
|
||||
return property.getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the property type.
|
||||
*
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public ResolvableType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the annotations on the type of the property.
|
||||
*
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public MergedAnnotations getTypeAnnotations() {
|
||||
return typeAnnotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the write method for the property is present.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public boolean hasWriteMethod() {
|
||||
return property.getWriteMethod() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link MergedAnnotation} of the given type.
|
||||
*
|
||||
* @param <T> the annotation type.
|
||||
* @param type must not be {@literal null}.
|
||||
* @return the {@link MergedAnnotation} if available or {@link MergedAnnotation#missing()} if not.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T extends Annotation> MergedAnnotation<T> getAnnotation(Class<T> type) {
|
||||
|
||||
Assert.notNull(type, "Type must not be null!");
|
||||
|
||||
return (MergedAnnotation<T>) annotationCache.computeIfAbsent(type, it -> lookupAnnotation(type));
|
||||
}
|
||||
|
||||
private <T extends Annotation> MergedAnnotation<T> lookupAnnotation(Class<T> type) {
|
||||
|
||||
return this.annotations.stream() //
|
||||
.map(it -> it.get(type)) //
|
||||
.filter(it -> it != null && it.isPresent()) //
|
||||
.findFirst() //
|
||||
.orElse(MergedAnnotation.missing());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default {@link PropertyMetadata} implementation, considering accessor methods and Jackson annotations to calculate
|
||||
* the metadata settings.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
private static class DefaultPropertyMetadata implements PropertyMetadata, Comparable<DefaultPropertyMetadata> {
|
||||
|
||||
private static Comparator<PropertyMetadata> BY_NAME = Comparator.comparing(PropertyMetadata::getName);
|
||||
|
||||
private final AnnotatedProperty property;
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.PropertyMetadata#getName()
|
||||
*/
|
||||
@Override
|
||||
public String getName() {
|
||||
return property.getName();
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.PropertyMetadata#isRequired()
|
||||
*/
|
||||
@Override
|
||||
public boolean isRequired() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.PropertyMetadata#isReadOnly()
|
||||
*/
|
||||
@Override
|
||||
public boolean isReadOnly() {
|
||||
|
||||
if (!property.hasWriteMethod()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
MergedAnnotation<JsonProperty> annotation = property.getAnnotation(JsonProperty.class);
|
||||
|
||||
return !annotation.isPresent() //
|
||||
? false //
|
||||
: Access.READ_ONLY.equals(annotation.getEnum("access", Access.class));
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.PropertyMetadata#getRegex()
|
||||
*/
|
||||
@Override
|
||||
public Optional<String> getPattern() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.AffordanceModel.PropertyMetadata#getType()
|
||||
*/
|
||||
@Override
|
||||
public ResolvableType getType() {
|
||||
return property.getType();
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see java.lang.Comparable#compareTo(java.lang.Object)
|
||||
*/
|
||||
@Override
|
||||
@SuppressWarnings("null")
|
||||
public int compareTo(DefaultPropertyMetadata that) {
|
||||
return BY_NAME.compare(this, that);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link PropertyMetadata} aware of JSR-303 annotationns.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
private static class Jsr303AwarePropertyMetadata extends DefaultPropertyMetadata {
|
||||
|
||||
private final AnnotatedProperty property;
|
||||
|
||||
/**
|
||||
* Creates a new {@link Jsr303AwarePropertyMetadata} instance for the given {@link AnnotatedProperty}.
|
||||
*
|
||||
* @param property must not be {@literal null}.
|
||||
*/
|
||||
private Jsr303AwarePropertyMetadata(AnnotatedProperty property) {
|
||||
|
||||
super(property);
|
||||
|
||||
this.property = property;
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.PropertyUtils.PropertyMetadata#isRequired()
|
||||
*/
|
||||
@Override
|
||||
public boolean isRequired() {
|
||||
return super.isRequired() || property.getAnnotation(NotNull.class).isPresent();
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.PropertyUtils.PropertyMetadata#getRegex()
|
||||
*/
|
||||
@Override
|
||||
public Optional<String> getPattern() {
|
||||
|
||||
MergedAnnotation<Pattern> annotation = property.getAnnotation(Pattern.class);
|
||||
|
||||
if (annotation.isPresent()) {
|
||||
return fromAnnotation(annotation);
|
||||
}
|
||||
|
||||
annotation = property.getTypeAnnotations().get(Pattern.class);
|
||||
|
||||
return annotation.isPresent() //
|
||||
? fromAnnotation(annotation) //
|
||||
: Optional.empty();
|
||||
}
|
||||
|
||||
private static Optional<String> fromAnnotation(MergedAnnotation<Pattern> annotation) {
|
||||
|
||||
return Optional.of(annotation.getString("regexp")) //
|
||||
.filter(StringUtils::hasText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2019 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 lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
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;
|
||||
import org.springframework.hateoas.AffordanceModel.PropertyMetadataConfigured;
|
||||
|
||||
/**
|
||||
* {@link InputPayloadMetadata} implementation based on a Java type.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
class TypeBasedPayloadMetadata implements InputPayloadMetadata {
|
||||
|
||||
private final @Getter(AccessLevel.PACKAGE) ResolvableType type;
|
||||
private final SortedMap<String, PropertyMetadata> properties;
|
||||
|
||||
TypeBasedPayloadMetadata(ResolvableType type, Stream<PropertyMetadata> properties) {
|
||||
|
||||
this.type = type;
|
||||
this.properties = new TreeMap<>(
|
||||
properties.collect(Collectors.toMap(PropertyMetadata::getName, Function.identity())));
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.PayloadMetadata#customize(T)
|
||||
*/
|
||||
@Override
|
||||
public <T extends PropertyMetadataConfigured<T> & 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)
|
||||
*/
|
||||
@Override
|
||||
public <T extends Named> T customize(T target, Function<PropertyMetadata, T> customizer) {
|
||||
|
||||
PropertyMetadata metadata = this.properties.get(target.getName());
|
||||
|
||||
return metadata == null ? target : customizer.apply(metadata);
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.PayloadMetadata#stream()
|
||||
*/
|
||||
@Override
|
||||
public Stream<PropertyMetadata> stream() {
|
||||
return properties.values().stream();
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.context.MessageSourceResolvable#getCodes()
|
||||
*/
|
||||
@Override
|
||||
public List<String> getI18nCodes() {
|
||||
|
||||
Class<?> type = this.type.resolve(Object.class);
|
||||
|
||||
return Arrays.asList(type.getName(), type.getSimpleName());
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,10 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.hateoas.Affordance;
|
||||
import org.springframework.hateoas.AffordanceModel;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.QueryParameter;
|
||||
import org.springframework.hateoas.mediatype.PropertyUtils;
|
||||
import org.springframework.http.HttpMethod;
|
||||
|
||||
/**
|
||||
@@ -47,8 +45,8 @@ class CollectionJsonAffordanceModel extends AffordanceModel {
|
||||
private final @Getter List<CollectionJsonData> inputProperties;
|
||||
private final @Getter List<CollectionJsonData> queryProperties;
|
||||
|
||||
public CollectionJsonAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType,
|
||||
List<QueryParameter> queryMethodParameters, ResolvableType outputType) {
|
||||
public CollectionJsonAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType,
|
||||
List<QueryParameter> queryMethodParameters, PayloadMetadata outputType) {
|
||||
|
||||
super(name, link, httpMethod, inputType, queryMethodParameters, outputType);
|
||||
|
||||
@@ -66,7 +64,7 @@ class CollectionJsonAffordanceModel extends AffordanceModel {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return PropertyUtils.findPropertyNames(getInputType()).stream() //
|
||||
return getInput().stream().map(PropertyMetadata::getName) //
|
||||
.map(propertyName -> new CollectionJsonData() //
|
||||
.withName(propertyName) //
|
||||
.withValue("")) //
|
||||
|
||||
@@ -19,9 +19,10 @@ import lombok.Getter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.hateoas.AffordanceModel;
|
||||
import org.springframework.hateoas.AffordanceModelFactory;
|
||||
import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
|
||||
import org.springframework.hateoas.AffordanceModel.PayloadMetadata;
|
||||
import org.springframework.hateoas.mediatype.AffordanceModelFactory;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.hateoas.QueryParameter;
|
||||
@@ -38,7 +39,8 @@ class CollectionJsonAffordanceModelFactory implements AffordanceModelFactory {
|
||||
private final @Getter MediaType mediaType = MediaTypes.COLLECTION_JSON;
|
||||
|
||||
@Override
|
||||
public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List<QueryParameter> queryMethodParameters, ResolvableType outputType) {
|
||||
public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod,
|
||||
InputPayloadMetadata inputType, List<QueryParameter> queryMethodParameters, PayloadMetadata outputType) {
|
||||
return new CollectionJsonAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class CollectionJsonItem<T> {
|
||||
return Collections.singletonList(new CollectionJsonData().withValue(this.rawData));
|
||||
}
|
||||
|
||||
return PropertyUtils.findProperties(this.rawData).entrySet().stream() //
|
||||
return PropertyUtils.extractPropertyValues(this.rawData).entrySet().stream() //
|
||||
.map(entry -> new CollectionJsonData() //
|
||||
.withName(entry.getKey()) //
|
||||
.withValue(entry.getValue())) //
|
||||
|
||||
@@ -37,6 +37,7 @@ import org.springframework.hateoas.mediatype.JacksonHelper;
|
||||
import org.springframework.hateoas.mediatype.PropertyUtils;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
@@ -947,6 +948,7 @@ class Jackson2CollectionJsonModule extends SimpleModule {
|
||||
|
||||
return selfLink.getAffordances().stream() //
|
||||
.map(it -> it.getAffordanceModel(MediaTypes.COLLECTION_JSON)) //
|
||||
.peek(it -> Assert.notNull(it, "No Collection/JSON affordance model found but expected!"))
|
||||
.map(CollectionJsonAffordanceModel.class::cast) //
|
||||
.filter(it -> !it.hasHttpMethod(HttpMethod.GET)) //
|
||||
.filter(it -> !it.pointsToTargetOf(selfLink)) //
|
||||
|
||||
@@ -26,12 +26,10 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.hateoas.Affordance;
|
||||
import org.springframework.hateoas.AffordanceModel;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.QueryParameter;
|
||||
import org.springframework.hateoas.mediatype.PropertyUtils;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
@@ -45,12 +43,11 @@ import org.springframework.http.MediaType;
|
||||
class HalFormsAffordanceModel extends AffordanceModel {
|
||||
|
||||
private static final Set<HttpMethod> ENTITY_ALTERING_METHODS = EnumSet.of(POST, PUT, PATCH);
|
||||
private static final Set<HttpMethod> REQUIRED_METHODS = EnumSet.of(POST, PUT);
|
||||
|
||||
private final @Getter List<HalFormsProperty> inputProperties;
|
||||
|
||||
public HalFormsAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType,
|
||||
List<QueryParameter> queryMethodParameters, ResolvableType outputType) {
|
||||
public HalFormsAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType,
|
||||
List<QueryParameter> queryMethodParameters, PayloadMetadata outputType) {
|
||||
|
||||
super(name, link, httpMethod, inputType, queryMethodParameters, outputType);
|
||||
|
||||
@@ -67,10 +64,10 @@ class HalFormsAffordanceModel extends AffordanceModel {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return PropertyUtils.findPropertyNames(getInputType()).stream() //
|
||||
.map(propertyName -> new HalFormsProperty() //
|
||||
.withName(propertyName) //
|
||||
.withRequired(REQUIRED_METHODS.contains(getHttpMethod()))) //
|
||||
return getInput().stream() //
|
||||
.map(PropertyMetadata::getName) //
|
||||
.map(it -> new HalFormsProperty() //
|
||||
.withName(it)) //
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@ import lombok.Getter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.hateoas.AffordanceModel;
|
||||
import org.springframework.hateoas.AffordanceModelFactory;
|
||||
import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
|
||||
import org.springframework.hateoas.AffordanceModel.PayloadMetadata;
|
||||
import org.springframework.hateoas.mediatype.AffordanceModelFactory;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.hateoas.QueryParameter;
|
||||
@@ -43,8 +44,8 @@ class HalFormsAffordanceModelFactory implements AffordanceModelFactory {
|
||||
* @see org.springframework.hateoas.AffordanceModelFactory#getAffordanceModel(java.lang.String, org.springframework.hateoas.Link, org.springframework.http.HttpMethod, org.springframework.core.ResolvableType, java.util.List, org.springframework.core.ResolvableType)
|
||||
*/
|
||||
@Override
|
||||
public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType,
|
||||
List<QueryParameter> queryMethodParameters, ResolvableType outputType) {
|
||||
return new HalFormsAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType);
|
||||
public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod,
|
||||
InputPayloadMetadata inputType, List<QueryParameter> parameters, PayloadMetadata outputType) {
|
||||
return new HalFormsAffordanceModel(name, link, httpMethod, inputType, parameters, outputType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ package org.springframework.hateoas.mediatype.hal.forms;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.hateoas.mediatype.hal.HalConfiguration;
|
||||
|
||||
/**
|
||||
@@ -30,6 +35,7 @@ import org.springframework.hateoas.mediatype.hal.HalConfiguration;
|
||||
public class HalFormsConfiguration {
|
||||
|
||||
private final @Getter HalConfiguration halConfiguration;
|
||||
private final Map<Class<?>, String> patterns = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Creates a new {@link HalFormsConfiguration} backed by a default {@link HalConfiguration}.
|
||||
@@ -37,4 +43,21 @@ public class HalFormsConfiguration {
|
||||
public HalFormsConfiguration() {
|
||||
this.halConfiguration = new HalConfiguration();
|
||||
}
|
||||
|
||||
public HalFormsConfiguration registerPattern(Class<?> type, String pattern) {
|
||||
|
||||
patterns.put(type, pattern);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the regular expression pattern that is registered for the given type.
|
||||
*
|
||||
* @param type must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
Optional<String> getTypePatternFor(ResolvableType type) {
|
||||
return Optional.ofNullable(patterns.get(type.resolve(Object.class)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,24 +58,24 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
@Value
|
||||
@Wither
|
||||
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
|
||||
@JsonPropertyOrder({ "attributes", "resource", "resources", "embedded", "links", "templates", "metadata" })
|
||||
@JsonPropertyOrder({ "attributes", "entity", "entities", "embedded", "links", "templates", "metadata" })
|
||||
public class HalFormsDocument<T> {
|
||||
|
||||
@Nullable //
|
||||
@Getter(onMethod = @__(@JsonAnyGetter))
|
||||
@JsonInclude(Include.NON_EMPTY) //
|
||||
@Getter(onMethod = @__(@JsonAnyGetter)) @JsonInclude(Include.NON_EMPTY) //
|
||||
@Wither(AccessLevel.PRIVATE) //
|
||||
private Map<String, Object> attributes;
|
||||
|
||||
@Nullable //
|
||||
@JsonUnwrapped //
|
||||
@JsonInclude(Include.NON_NULL) //
|
||||
private T resource;
|
||||
private T entity;
|
||||
|
||||
@Nullable //
|
||||
@JsonInclude(Include.NON_EMPTY) //
|
||||
@JsonIgnore //
|
||||
@Wither(AccessLevel.PRIVATE) //
|
||||
private Collection<T> resources;
|
||||
private Collection<T> entities;
|
||||
|
||||
@JsonProperty("_embedded") //
|
||||
@JsonInclude(Include.NON_EMPTY) //
|
||||
@@ -86,14 +86,12 @@ public class HalFormsDocument<T> {
|
||||
@JsonInclude(Include.NON_NULL) //
|
||||
private PagedModel.PageMetadata pageMetadata;
|
||||
|
||||
// @Singular //
|
||||
@JsonProperty("_links") //
|
||||
@JsonInclude(Include.NON_EMPTY) //
|
||||
@JsonSerialize(using = HalLinkListSerializer.class) //
|
||||
@JsonDeserialize(using = HalFormsLinksDeserializer.class) //
|
||||
private Links links;
|
||||
|
||||
// @Singular //
|
||||
@JsonProperty("_templates") //
|
||||
@JsonInclude(Include.NON_EMPTY) //
|
||||
private Map<String, HalFormsTemplate> templates;
|
||||
@@ -110,7 +108,7 @@ public class HalFormsDocument<T> {
|
||||
*/
|
||||
public static HalFormsDocument<?> forRepresentationModel(RepresentationModel<?> model) {
|
||||
|
||||
Map<String, Object> attributes = PropertyUtils.findProperties(model);
|
||||
Map<String, Object> attributes = PropertyUtils.extractPropertyValues(model);
|
||||
attributes.remove("links");
|
||||
|
||||
return new HalFormsDocument<>().withAttributes(attributes);
|
||||
@@ -122,21 +120,21 @@ public class HalFormsDocument<T> {
|
||||
* @param resource can be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public static <T> HalFormsDocument<T> forResource(@Nullable T resource) {
|
||||
return new HalFormsDocument<T>().withResource(resource);
|
||||
public static <T> HalFormsDocument<T> forEntity(@Nullable T resource) {
|
||||
return new HalFormsDocument<T>().withEntity(resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* returns a new {@link HalFormsDocument} for the given resources.
|
||||
*
|
||||
* @param resources must not be {@literal null}.
|
||||
* @param entities must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public static <T> HalFormsDocument<T> forResources(Collection<T> resources) {
|
||||
public static <T> HalFormsDocument<T> forEntities(Collection<T> entities) {
|
||||
|
||||
Assert.notNull(resources, "Resources must not be null!");
|
||||
Assert.notNull(entities, "Resources must not be null!");
|
||||
|
||||
return new HalFormsDocument<T>().withResources(resources);
|
||||
return new HalFormsDocument<T>().withEntities(entities);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,11 +171,11 @@ public class HalFormsDocument<T> {
|
||||
}
|
||||
|
||||
public HalFormsDocument<T> withPageMetadata(@Nullable PageMetadata metadata) {
|
||||
return new HalFormsDocument<T>(attributes, resource, resources, embedded, metadata, links, templates);
|
||||
return new HalFormsDocument<T>(attributes, entity, entities, embedded, metadata, links, templates);
|
||||
}
|
||||
|
||||
private HalFormsDocument<T> withResource(@Nullable T resource) {
|
||||
return new HalFormsDocument<T>(attributes, resource, resources, embedded, pageMetadata, links, templates);
|
||||
private HalFormsDocument<T> withEntity(@Nullable T entity) {
|
||||
return new HalFormsDocument<T>(attributes, entity, entities, embedded, pageMetadata, links, templates);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,7 +188,7 @@ public class HalFormsDocument<T> {
|
||||
|
||||
Assert.notNull(link, "Link must not be null!");
|
||||
|
||||
return new HalFormsDocument<>(attributes, resource, resources, embedded, pageMetadata, links.and(link), templates);
|
||||
return new HalFormsDocument<>(attributes, entity, entities, embedded, pageMetadata, links.and(link), templates);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,7 +206,7 @@ public class HalFormsDocument<T> {
|
||||
Map<String, HalFormsTemplate> templates = new HashMap<>(this.templates);
|
||||
templates.put(name, template);
|
||||
|
||||
return new HalFormsDocument<>(attributes, resource, resources, embedded, pageMetadata, links, templates);
|
||||
return new HalFormsDocument<>(attributes, entity, entities, embedded, pageMetadata, links, templates);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,6 +224,6 @@ public class HalFormsDocument<T> {
|
||||
Map<HalLinkRelation, Object> embedded = new HashMap<>(this.embedded);
|
||||
embedded.put(key, value);
|
||||
|
||||
return new HalFormsDocument<>(attributes, resource, resources, embedded, pageMetadata, links, templates);
|
||||
return new HalFormsDocument<>(attributes, entity, entities, embedded, pageMetadata, links, templates);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,14 @@ import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.NonNull;
|
||||
import lombok.ToString;
|
||||
import lombok.Value;
|
||||
import lombok.experimental.Wither;
|
||||
|
||||
import org.springframework.hateoas.AffordanceModel.Named;
|
||||
import org.springframework.hateoas.AffordanceModel.PropertyMetadata;
|
||||
import org.springframework.hateoas.AffordanceModel.PropertyMetadataConfigured;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
|
||||
@@ -36,15 +41,16 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
@Wither
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@NoArgsConstructor(force = true)
|
||||
public class HalFormsProperty {
|
||||
@ToString
|
||||
public class HalFormsProperty implements PropertyMetadataConfigured<HalFormsProperty>, Named {
|
||||
|
||||
private @NonNull String name;
|
||||
private Boolean readOnly;
|
||||
private @JsonInclude(Include.NON_DEFAULT) boolean readOnly;
|
||||
private String value;
|
||||
private String prompt;
|
||||
private @JsonInclude(Include.NON_EMPTY) String prompt;
|
||||
private String regex;
|
||||
private boolean templated;
|
||||
private @JsonInclude boolean required;
|
||||
private @JsonInclude(Include.NON_DEFAULT) boolean required;
|
||||
private boolean multi;
|
||||
|
||||
/**
|
||||
@@ -53,8 +59,21 @@ public class HalFormsProperty {
|
||||
* @param name must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
|
||||
public static HalFormsProperty named(String name) {
|
||||
return new HalFormsProperty().withName(name);
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.PropertyMetadataAware#apply(org.springframework.hateoas.mediatype.PropertyUtils.PropertyMetadata)
|
||||
*/
|
||||
public HalFormsProperty apply(PropertyMetadata metadata) {
|
||||
|
||||
HalFormsProperty customized = withRequired(metadata.isRequired()) //
|
||||
.withReadOnly(metadata.isReadOnly());
|
||||
|
||||
return metadata.getPattern() //
|
||||
.map(customized::withRegex) //
|
||||
.orElse(customized);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,32 +15,16 @@
|
||||
*/
|
||||
package org.springframework.hateoas.mediatype.hal.forms;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.context.MessageSourceResolvable;
|
||||
import org.springframework.context.support.MessageSourceAccessor;
|
||||
import org.springframework.hateoas.Affordance;
|
||||
import org.springframework.hateoas.CollectionModel;
|
||||
import org.springframework.hateoas.EntityModel;
|
||||
import org.springframework.hateoas.IanaLinkRelations;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.hateoas.PagedModel;
|
||||
import org.springframework.hateoas.RepresentationModel;
|
||||
import org.springframework.hateoas.mediatype.hal.HalLinkRelation;
|
||||
import org.springframework.hateoas.mediatype.hal.Jackson2HalModule;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.BeanProperty;
|
||||
@@ -64,18 +48,19 @@ class HalFormsSerializers {
|
||||
|
||||
private static final long serialVersionUID = -4583146321934407153L;
|
||||
|
||||
private final MessageSourceAccessor accessor;
|
||||
private final HalFormsTemplateBuilder builder;
|
||||
private final BeanProperty property;
|
||||
|
||||
HalFormsRepresentationModelSerializer(MessageSourceAccessor accessor, @Nullable BeanProperty property) {
|
||||
HalFormsRepresentationModelSerializer(HalFormsTemplateBuilder builder, @Nullable BeanProperty property) {
|
||||
|
||||
super(RepresentationModel.class, false);
|
||||
|
||||
this.builder = builder;
|
||||
this.property = property;
|
||||
this.accessor = accessor;
|
||||
}
|
||||
|
||||
HalFormsRepresentationModelSerializer(MessageSourceAccessor accessor) {
|
||||
this(accessor, null);
|
||||
HalFormsRepresentationModelSerializer(HalFormsTemplateBuilder customizations) {
|
||||
this(customizations, null);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -89,7 +74,7 @@ class HalFormsSerializers {
|
||||
|
||||
HalFormsDocument<?> doc = HalFormsDocument.forRepresentationModel(value) //
|
||||
.withLinks(value.getLinks()) //
|
||||
.withTemplates(findTemplates(value, accessor));
|
||||
.withTemplates(builder.findTemplates(value));
|
||||
|
||||
provider.findValueSerializer(HalFormsDocument.class, property).serialize(doc, gen, provider);
|
||||
}
|
||||
@@ -124,7 +109,7 @@ class HalFormsSerializers {
|
||||
@Override
|
||||
@SuppressWarnings("null")
|
||||
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
|
||||
return new HalFormsRepresentationModelSerializer(accessor, property);
|
||||
return new HalFormsRepresentationModelSerializer(builder, property);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,18 +121,19 @@ class HalFormsSerializers {
|
||||
|
||||
private static final long serialVersionUID = -7912243216469101379L;
|
||||
|
||||
private final MessageSourceAccessor accessor;
|
||||
private final HalFormsTemplateBuilder builder;
|
||||
private final BeanProperty property;
|
||||
|
||||
HalFormsEntityModelSerializer(MessageSourceAccessor accessor, @Nullable BeanProperty property) {
|
||||
HalFormsEntityModelSerializer(HalFormsTemplateBuilder builder, @Nullable BeanProperty property) {
|
||||
|
||||
super(EntityModel.class, false);
|
||||
this.accessor = accessor;
|
||||
|
||||
this.builder = builder;
|
||||
this.property = property;
|
||||
}
|
||||
|
||||
HalFormsEntityModelSerializer(MessageSourceAccessor accessor) {
|
||||
this(accessor, null);
|
||||
HalFormsEntityModelSerializer(HalFormsTemplateBuilder builder) {
|
||||
this(builder, null);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -158,9 +144,9 @@ class HalFormsSerializers {
|
||||
@SuppressWarnings("null")
|
||||
public void serialize(EntityModel<?> value, JsonGenerator gen, SerializerProvider provider) throws IOException {
|
||||
|
||||
HalFormsDocument<?> doc = HalFormsDocument.forResource(value.getContent()) //
|
||||
HalFormsDocument<?> doc = HalFormsDocument.forEntity(value.getContent()) //
|
||||
.withLinks(value.getLinks()) //
|
||||
.withTemplates(findTemplates(value, accessor));
|
||||
.withTemplates(builder.findTemplates(value));
|
||||
|
||||
provider.findValueSerializer(HalFormsDocument.class, property).serialize(doc, gen, provider);
|
||||
}
|
||||
@@ -214,7 +200,7 @@ class HalFormsSerializers {
|
||||
@SuppressWarnings("null")
|
||||
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property)
|
||||
throws JsonMappingException {
|
||||
return new HalFormsEntityModelSerializer(accessor, property);
|
||||
return new HalFormsEntityModelSerializer(builder, property);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,20 +214,21 @@ class HalFormsSerializers {
|
||||
|
||||
private final BeanProperty property;
|
||||
private final Jackson2HalModule.EmbeddedMapper embeddedMapper;
|
||||
private final MessageSourceAccessor accessor;
|
||||
private final HalFormsTemplateBuilder customizations;
|
||||
|
||||
HalFormsCollectionModelSerializer(MessageSourceAccessor accessor, @Nullable BeanProperty property,
|
||||
HalFormsCollectionModelSerializer(HalFormsTemplateBuilder customizations, @Nullable BeanProperty property,
|
||||
Jackson2HalModule.EmbeddedMapper embeddedMapper) {
|
||||
|
||||
super(CollectionModel.class, false);
|
||||
|
||||
this.property = property;
|
||||
this.embeddedMapper = embeddedMapper;
|
||||
this.accessor = accessor;
|
||||
this.customizations = customizations;
|
||||
}
|
||||
|
||||
HalFormsCollectionModelSerializer(MessageSourceAccessor accessor, Jackson2HalModule.EmbeddedMapper embeddedMapper) {
|
||||
this(accessor, null, embeddedMapper);
|
||||
HalFormsCollectionModelSerializer(HalFormsTemplateBuilder customizations,
|
||||
Jackson2HalModule.EmbeddedMapper embeddedMapper) {
|
||||
this(customizations, null, embeddedMapper);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -262,14 +249,14 @@ class HalFormsSerializers {
|
||||
.withEmbedded(embeddeds) //
|
||||
.withPageMetadata(((PagedModel<?>) value).getMetadata()) //
|
||||
.withLinks(value.getLinks()) //
|
||||
.withTemplates(findTemplates(value, accessor));
|
||||
.withTemplates(customizations.findTemplates(value));
|
||||
|
||||
} else {
|
||||
|
||||
doc = HalFormsDocument.empty() //
|
||||
.withEmbedded(embeddeds) //
|
||||
.withLinks(value.getLinks()) //
|
||||
.withTemplates(findTemplates(value, accessor));
|
||||
.withTemplates(customizations.findTemplates(value));
|
||||
}
|
||||
|
||||
provider.findValueSerializer(HalFormsDocument.class, property).serialize(doc, gen, provider);
|
||||
@@ -324,137 +311,7 @@ class HalFormsSerializers {
|
||||
@SuppressWarnings("null")
|
||||
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property)
|
||||
throws JsonMappingException {
|
||||
return new HalFormsCollectionModelSerializer(accessor, property, embeddedMapper);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract template details from a {@link RepresentationModel}'s {@link Affordance}s.
|
||||
*
|
||||
* @param resource
|
||||
* @return
|
||||
*/
|
||||
private static Map<String, HalFormsTemplate> findTemplates(RepresentationModel<?> resource,
|
||||
MessageSourceAccessor accessor) {
|
||||
|
||||
if (!resource.hasLink(IanaLinkRelations.SELF)) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
Map<String, HalFormsTemplate> templates = new HashMap<>();
|
||||
List<Affordance> affordances = resource.getLink(IanaLinkRelations.SELF) //
|
||||
.map(Link::getAffordances) //
|
||||
.orElse(Collections.emptyList());
|
||||
|
||||
affordances.stream() //
|
||||
.map(it -> it.getAffordanceModel(MediaTypes.HAL_FORMS_JSON)) //
|
||||
.map(HalFormsAffordanceModel.class::cast) //
|
||||
.filter(it -> !it.hasHttpMethod(HttpMethod.GET)) //
|
||||
.forEach(it -> {
|
||||
|
||||
Class<?> type = it.getInputType().resolve(Object.class);
|
||||
|
||||
List<HalFormsProperty> propertiesWithPrompt = it.getInputProperties().stream() //
|
||||
.map(property -> property.withPrompt(accessor.getMessage(PropertyPrompt.of(type, property))))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
HalFormsTemplate template = HalFormsTemplate.forMethod(it.getHttpMethod()) //
|
||||
.withProperties(propertiesWithPrompt);
|
||||
|
||||
String defaultedName = templates.isEmpty() ? "default" : it.getName();
|
||||
String title = accessor.getMessage(TemplateTitle.of(it, templates.isEmpty()));
|
||||
|
||||
if (StringUtils.hasText(title)) {
|
||||
template = template.withTitle(title);
|
||||
}
|
||||
|
||||
/*
|
||||
* First template in HAL-FORMS is "default".
|
||||
*/
|
||||
templates.put(defaultedName, template);
|
||||
});
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
@RequiredArgsConstructor(staticName = "of")
|
||||
static class TemplateTitle implements MessageSourceResolvable {
|
||||
|
||||
private static final String TEMPLATE_TEMPLATE = "_templates.%s.title";
|
||||
|
||||
private final HalFormsAffordanceModel affordance;
|
||||
private final boolean soleTemplate;
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.context.MessageSourceResolvable#getCodes()
|
||||
*/
|
||||
@NonNull
|
||||
@Override
|
||||
public String[] getCodes() {
|
||||
|
||||
Stream<String> seed = Stream.concat(//
|
||||
Stream.of(affordance.getName()), //
|
||||
soleTemplate ? Stream.of("default") : Stream.empty());
|
||||
|
||||
Class<?> type = affordance.getInputType().resolve(Object.class);
|
||||
|
||||
return seed.flatMap(it -> getCodesFor(it, type)) //
|
||||
.toArray(String[]::new);
|
||||
}
|
||||
|
||||
private static Stream<String> getCodesFor(String name, Class<?> type) {
|
||||
|
||||
String global = String.format(TEMPLATE_TEMPLATE, name);
|
||||
|
||||
return Stream.of(//
|
||||
String.format("%s.%s", type.getName(), global), //
|
||||
String.format("%s.%s", type.getSimpleName(), global), //
|
||||
global);
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.context.MessageSourceResolvable#getDefaultMessage()
|
||||
*/
|
||||
@Nullable
|
||||
@Override
|
||||
public String getDefaultMessage() {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@RequiredArgsConstructor(staticName = "of")
|
||||
static class PropertyPrompt implements MessageSourceResolvable {
|
||||
|
||||
private static final String PROMPT_TEMPLATE = "%s._prompt";
|
||||
|
||||
private final Class<?> type;
|
||||
private final HalFormsProperty property;
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.context.MessageSourceResolvable#getDefaultMessage()
|
||||
*/
|
||||
@Nullable
|
||||
@Override
|
||||
public String getDefaultMessage() {
|
||||
return StringUtils.capitalize(property.getName());
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.context.MessageSourceResolvable#getCodes()
|
||||
*/
|
||||
@NonNull
|
||||
@Override
|
||||
public String[] getCodes() {
|
||||
|
||||
String globalCode = String.format(PROMPT_TEMPLATE, property.getName());
|
||||
String localCode = String.format("%s.%s", type.getSimpleName(), globalCode);
|
||||
String qualifiedCode = String.format("%s.%s", type.getName(), globalCode);
|
||||
|
||||
return new String[] { qualifiedCode, localCode, globalCode };
|
||||
return new HalFormsCollectionModelSerializer(customizations, property, embeddedMapper);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import lombok.experimental.Wither;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.hateoas.mediatype.hal.forms.HalFormsDeserializers.MediaTypesDeserializer;
|
||||
import org.springframework.http.HttpMethod;
|
||||
@@ -37,6 +38,8 @@ import org.springframework.util.StringUtils;
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
|
||||
@@ -60,7 +63,7 @@ public class HalFormsTemplate {
|
||||
|
||||
public static final String DEFAULT_KEY = "default";
|
||||
|
||||
private String title;
|
||||
private @JsonInclude(Include.NON_EMPTY) String title;
|
||||
private @Wither(AccessLevel.PRIVATE) HttpMethod httpMethod;
|
||||
private List<HalFormsProperty> properties;
|
||||
private List<MediaType> contentTypes;
|
||||
@@ -108,6 +111,7 @@ public class HalFormsTemplate {
|
||||
|
||||
// Jackson helper methods to create the right representation format
|
||||
|
||||
@JsonInclude(Include.NON_EMPTY)
|
||||
String getContentType() {
|
||||
return StringUtils.collectionToDelimitedString(contentTypes, ", ");
|
||||
}
|
||||
@@ -125,4 +129,8 @@ public class HalFormsTemplate {
|
||||
void setMethod(String method) {
|
||||
this.httpMethod = HttpMethod.valueOf(method.toUpperCase());
|
||||
}
|
||||
|
||||
Optional<HalFormsProperty> getPropertyByName(String name) {
|
||||
return properties.stream().filter(it -> it.getName().equals(name)).findFirst();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* Copyright 2019 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 lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.context.MessageSourceResolvable;
|
||||
import org.springframework.context.support.MessageSourceAccessor;
|
||||
import org.springframework.hateoas.Affordance;
|
||||
import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
|
||||
import org.springframework.hateoas.AffordanceModel.PropertyMetadata;
|
||||
import org.springframework.hateoas.IanaLinkRelations;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.hateoas.RepresentationModel;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
class HalFormsTemplateBuilder {
|
||||
|
||||
private final HalFormsConfiguration configuration;
|
||||
private final MessageSourceAccessor accessor;
|
||||
|
||||
/**
|
||||
* Extract template details from a {@link RepresentationModel}'s {@link Affordance}s.
|
||||
*
|
||||
* @param resource
|
||||
* @return
|
||||
*/
|
||||
public Map<String, HalFormsTemplate> findTemplates(RepresentationModel<?> resource) {
|
||||
|
||||
if (!resource.hasLink(IanaLinkRelations.SELF)) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
Map<String, HalFormsTemplate> templates = new HashMap<>();
|
||||
List<Affordance> affordances = resource.getLink(IanaLinkRelations.SELF) //
|
||||
.map(Link::getAffordances) //
|
||||
.orElse(Collections.emptyList());
|
||||
|
||||
affordances.stream() //
|
||||
.map(it -> it.getAffordanceModel(MediaTypes.HAL_FORMS_JSON)) //
|
||||
.peek(it -> {
|
||||
Assert.notNull(it, "No HAL Forms affordance model found but expected!");
|
||||
}) //
|
||||
.map(HalFormsAffordanceModel.class::cast) //
|
||||
.filter(it -> !it.hasHttpMethod(HttpMethod.GET)) //
|
||||
.forEach(it -> {
|
||||
|
||||
PropertyCustomizations propertyCustomizations = forMetadata(it.getInput());
|
||||
|
||||
List<HalFormsProperty> 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);
|
||||
|
||||
template = applyTo(template, TemplateTitle.of(it, templates.isEmpty()));
|
||||
templates.put(templates.isEmpty() ? "default" : it.getName(), template);
|
||||
});
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
public PropertyCustomizations forMetadata(InputPayloadMetadata metadata) {
|
||||
return new PropertyCustomizations(metadata);
|
||||
}
|
||||
|
||||
public HalFormsTemplate applyTo(HalFormsTemplate template, HalFormsTemplateBuilder.TemplateTitle templateTitle) {
|
||||
|
||||
return Optional.ofNullable(accessor.getMessage(templateTitle)) //
|
||||
.filter(StringUtils::hasText) //
|
||||
.map(template::withTitle) //
|
||||
.orElse(template);
|
||||
}
|
||||
|
||||
@RequiredArgsConstructor
|
||||
class PropertyCustomizations {
|
||||
|
||||
private final InputPayloadMetadata metadata;
|
||||
|
||||
private HalFormsProperty apply(HalFormsProperty property) {
|
||||
|
||||
String message = accessor.getMessage(PropertyPrompt.of(metadata, property));
|
||||
HalFormsProperty withPrompt = property.withPrompt(message);
|
||||
HalFormsProperty withConfig = metadata.getPropertyMetadata(withPrompt.getName()) //
|
||||
.flatMap(it -> applyConfig(it, withPrompt)) //
|
||||
.orElse(withPrompt);
|
||||
|
||||
return metadata.applyTo(withConfig);
|
||||
}
|
||||
|
||||
private Optional<HalFormsProperty> applyConfig(PropertyMetadata metadata, HalFormsProperty property) {
|
||||
return configuration.getTypePatternFor(metadata.getType()).map(property::withRegex);
|
||||
}
|
||||
}
|
||||
|
||||
@RequiredArgsConstructor(staticName = "of")
|
||||
static class TemplateTitle implements MessageSourceResolvable {
|
||||
|
||||
private static final String TEMPLATE_TEMPLATE = "_templates.%s.title";
|
||||
|
||||
private final HalFormsAffordanceModel affordance;
|
||||
private final boolean soleTemplate;
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.context.MessageSourceResolvable#getCodes()
|
||||
*/
|
||||
@NonNull
|
||||
@Override
|
||||
public String[] getCodes() {
|
||||
|
||||
Stream<String> seed = Stream.concat(//
|
||||
Stream.of(affordance.getName()), //
|
||||
soleTemplate ? Stream.of("default") : Stream.empty());
|
||||
|
||||
return seed.flatMap(it -> getCodesFor(it, affordance.getInput())) //
|
||||
.toArray(String[]::new);
|
||||
}
|
||||
|
||||
private static Stream<String> getCodesFor(String name, InputPayloadMetadata type) {
|
||||
|
||||
String global = String.format(TEMPLATE_TEMPLATE, name);
|
||||
|
||||
Stream<String> inputBased = type.getI18nCodes().stream() //
|
||||
.map(it -> String.format("%s.%s", it, global));
|
||||
|
||||
return Stream.concat(inputBased, Stream.of(global));
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.context.MessageSourceResolvable#getDefaultMessage()
|
||||
*/
|
||||
@Nullable
|
||||
@Override
|
||||
public String getDefaultMessage() {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@RequiredArgsConstructor(staticName = "of")
|
||||
static class PropertyPrompt implements MessageSourceResolvable {
|
||||
|
||||
private static final String PROMPT_TEMPLATE = "%s._prompt";
|
||||
|
||||
private final InputPayloadMetadata metadata;
|
||||
private final HalFormsProperty 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<String> 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[codes.size()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,12 +168,13 @@ class Jackson2HalFormsModule extends SimpleModule {
|
||||
super(resolver, curieProvider, accessor, enforceEmbeddedCollections, configuration.getHalConfiguration());
|
||||
|
||||
EmbeddedMapper mapper = new EmbeddedMapper(resolver, curieProvider, enforceEmbeddedCollections);
|
||||
HalFormsTemplateBuilder builder = new HalFormsTemplateBuilder(configuration, accessor);
|
||||
|
||||
this.serializers.put(HalFormsRepresentationModelSerializer.class,
|
||||
new HalFormsRepresentationModelSerializer(accessor));
|
||||
this.serializers.put(HalFormsEntityModelSerializer.class, new HalFormsEntityModelSerializer(accessor));
|
||||
new HalFormsRepresentationModelSerializer(builder));
|
||||
this.serializers.put(HalFormsEntityModelSerializer.class, new HalFormsEntityModelSerializer(builder));
|
||||
this.serializers.put(HalFormsCollectionModelSerializer.class,
|
||||
new HalFormsCollectionModelSerializer(accessor, mapper));
|
||||
new HalFormsCollectionModelSerializer(builder, mapper));
|
||||
this.serializers.put(HalLinkListSerializer.class,
|
||||
new HalLinkListSerializer(curieProvider, mapper, accessor, configuration.getHalConfiguration()));
|
||||
}
|
||||
|
||||
@@ -24,12 +24,10 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.hateoas.AffordanceModel;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.hateoas.QueryParameter;
|
||||
import org.springframework.hateoas.mediatype.PropertyUtils;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.lang.Nullable;
|
||||
@@ -42,14 +40,16 @@ import org.springframework.lang.Nullable;
|
||||
*/
|
||||
class UberAffordanceModel extends AffordanceModel {
|
||||
|
||||
private static final Set<HttpMethod> ENTITY_ALTERING_METHODS = EnumSet.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH);
|
||||
private static final Set<HttpMethod> ENTITY_ALTERING_METHODS = EnumSet.of(HttpMethod.POST, HttpMethod.PUT,
|
||||
HttpMethod.PATCH);
|
||||
|
||||
private final @Getter Collection<MediaType> mediaTypes = Collections.singleton(MediaTypes.UBER_JSON);
|
||||
|
||||
|
||||
private final @Getter List<UberData> inputProperties;
|
||||
private final @Getter List<UberData> queryProperties;
|
||||
|
||||
UberAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List<QueryParameter> queryMethodParameters, ResolvableType outputType) {
|
||||
UberAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType,
|
||||
List<QueryParameter> queryMethodParameters, PayloadMetadata outputType) {
|
||||
super(name, link, httpMethod, inputType, queryMethodParameters, outputType);
|
||||
|
||||
this.inputProperties = determineAffordanceInputs();
|
||||
@@ -58,16 +58,16 @@ class UberAffordanceModel extends AffordanceModel {
|
||||
|
||||
private List<UberData> determineAffordanceInputs() {
|
||||
|
||||
if (ENTITY_ALTERING_METHODS.contains(getHttpMethod())) {
|
||||
|
||||
return PropertyUtils.findPropertyNames(getInputType()).stream()
|
||||
.map(propertyName -> new UberData()
|
||||
.withName(propertyName)
|
||||
.withValue(""))
|
||||
.collect(Collectors.toList());
|
||||
} else {
|
||||
if (!ENTITY_ALTERING_METHODS.contains(getHttpMethod())) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return getInput().stream()//
|
||||
.map(PropertyMetadata::getName) //
|
||||
.map(propertyName -> new UberData() //
|
||||
.withName(propertyName) //
|
||||
.withValue("")) //
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,10 +81,8 @@ class UberAffordanceModel extends AffordanceModel {
|
||||
|
||||
if (getHttpMethod().equals(HttpMethod.GET)) {
|
||||
return getQueryMethodParameters().stream()
|
||||
.map(queryParameter -> new UberData()
|
||||
.withName(queryParameter.getName())
|
||||
.withValue(""))
|
||||
.collect(Collectors.toList());
|
||||
.map(queryParameter -> new UberData().withName(queryParameter.getName()).withValue(""))
|
||||
.collect(Collectors.toList());
|
||||
} else {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@ import lombok.Getter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.hateoas.AffordanceModel;
|
||||
import org.springframework.hateoas.AffordanceModelFactory;
|
||||
import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
|
||||
import org.springframework.hateoas.AffordanceModel.PayloadMetadata;
|
||||
import org.springframework.hateoas.mediatype.AffordanceModelFactory;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.hateoas.QueryParameter;
|
||||
@@ -40,11 +41,11 @@ class UberAffordanceModelFactory implements AffordanceModelFactory {
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.AffordanceModelFactory#getAffordanceModel(java.lang.String, org.springframework.hateoas.Link, org.springframework.http.HttpMethod, org.springframework.core.ResolvableType, java.util.List, org.springframework.core.ResolvableType)
|
||||
* @see org.springframework.hateoas.AffordanceModelFactory#getAffordanceModel(java.lang.String, org.springframework.hateoas.Link, org.springframework.http.HttpMethod, org.springframework.hateoas.mediatype.PayloadMetadata, java.util.List, org.springframework.core.ResolvableType)
|
||||
*/
|
||||
@Override
|
||||
public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType,
|
||||
List<QueryParameter> queryMethodParameters, ResolvableType outputType) {
|
||||
public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod,
|
||||
InputPayloadMetadata inputType, List<QueryParameter> queryMethodParameters, PayloadMetadata outputType) {
|
||||
return new UberAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ class UberData {
|
||||
return Collections.singletonList(new UberData().withValue(obj));
|
||||
}
|
||||
|
||||
return PropertyUtils.findProperties(obj).entrySet().stream()
|
||||
return PropertyUtils.extractPropertyValues(obj).entrySet().stream()
|
||||
.map(entry -> new UberData().withName(entry.getKey()).withValue(entry.getValue())).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
@@ -17,15 +17,15 @@ package org.springframework.hateoas.server.core;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.hateoas.Affordance;
|
||||
import org.springframework.hateoas.AffordanceModelFactory;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.LinkRelation;
|
||||
import org.springframework.hateoas.QueryParameter;
|
||||
import org.springframework.hateoas.mediatype.AffordanceModelFactory;
|
||||
import org.springframework.hateoas.mediatype.Affordances;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
@@ -60,15 +60,20 @@ public class SpringAffordanceBuilder {
|
||||
.orElse(ResolvableType.NONE);
|
||||
|
||||
List<QueryParameter> queryMethodParameters = parameters.getParametersWith(RequestParam.class).stream() //
|
||||
.map(it -> it.getParameterAnnotation(RequestParam.class)) //
|
||||
.filter(Objects::nonNull) //
|
||||
.map(it -> new QueryParameter(it.name(), it.value(), it.required())) //
|
||||
.map(QueryParameter::of) //
|
||||
.collect(Collectors.toList());
|
||||
|
||||
ResolvableType outputType = ResolvableType.forMethodReturnType(method);
|
||||
Affordances affordances = Affordances.of(affordanceLink);
|
||||
|
||||
return discoverer.getRequestMethod(type, method).stream() //
|
||||
.map(it -> new Affordance(methodName, affordanceLink, it, inputType, queryMethodParameters, outputType)) //
|
||||
.flatMap(it -> affordances.afford(it) //
|
||||
.withInput(inputType) //
|
||||
.withOutput(outputType) //
|
||||
.withParameters(queryMethodParameters) //
|
||||
.withName(methodName) //
|
||||
.build() //
|
||||
.stream()) //
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user