diff --git a/pom.xml b/pom.xml index 1a5300236..b862fb3d4 100644 --- a/pom.xml +++ b/pom.xml @@ -33,7 +33,7 @@ 3.2.0.BUILD-SNAPSHOT 1.5.0.BUILD-SNAPSHOT - 4.2.0.Final + 4.3.5.Final false @@ -74,13 +74,6 @@ - - org.hibernate.javax.persistence - hibernate-jpa-2.0-api - 1.0.1.Final - test - - org.hibernate hibernate-entitymanager @@ -119,7 +112,7 @@ org.hsqldb hsqldb - 2.2.8 + 2.3.2 test diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/annotation/Description.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/annotation/Description.java index 373cafeae..f65002de1 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/annotation/Description.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/annotation/Description.java @@ -27,7 +27,7 @@ import java.lang.annotation.Target; * @author Jon Brisbin * @author Oliver Gierke */ -@Target({ ElementType.FIELD, ElementType.METHOD }) +@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) public @interface Description { diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/MetadataConfiguration.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/MetadataConfiguration.java new file mode 100644 index 000000000..95c5a01f3 --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/MetadataConfiguration.java @@ -0,0 +1,66 @@ +/* + * Copyright 2014 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 + * + * http://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.data.rest.core.config; + +/** + * Configuration for metadata exposure. + * + * @author Oliver Gierke + */ +public class MetadataConfiguration { + + private boolean omitUnresolvableDescriptionKeys = true; + private boolean alpsEnabled = true; + + /** + * Configures whether to omit documentation attributes for unresolvable resource bundle keys. Defaults to + * {@literal true}, which means that an unsuccessful attempt to resolve the message will cause no documentation entry + * to be rendered for the metadata resources. + * + * @param omitUnresolvableDescriptionKeys whether to omit documentation attributes for unresolvable resource bundle + * keys. + */ + public void setOmitUnresolvableDescriptionKeys(boolean omitUnresolvableDescriptionKeys) { + this.omitUnresolvableDescriptionKeys = omitUnresolvableDescriptionKeys; + } + + /** + * Returns whether to omit documentation attributes for unresolvable resource bundle keys. + * + * @return the omitUnresolvableDescriptionKeys + */ + public boolean omitUnresolvableDescriptionKeys() { + return omitUnresolvableDescriptionKeys; + } + + /** + * Configures whether to expose the ALPS resources. + * + * @param alpsEnabled the alpsEnabled to set + */ + public void setAlpsEnabled(boolean enableAlps) { + this.alpsEnabled = enableAlps; + } + + /** + * Returns whether the ALPS resources are exposed. + * + * @return the alpsEnabled + */ + public boolean alpsEnabled() { + return alpsEnabled; + } +} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/ProjectionDefinitionConfiguration.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/ProjectionDefinitionConfiguration.java index 9175fe344..03147f335 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/ProjectionDefinitionConfiguration.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/ProjectionDefinitionConfiguration.java @@ -17,6 +17,7 @@ package org.springframework.data.rest.core.config; import java.util.HashMap; import java.util.Map; +import java.util.Map.Entry; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.data.rest.core.projection.ProjectionDefinitions; @@ -142,6 +143,27 @@ public class ProjectionDefinitionConfiguration implements ProjectionDefinitions return false; } + /** + * Returns all projections registered for the given source type. + * + * @param sourceType must not be {@literal null}. + * @return + */ + public Map> getProjectionsFor(Class sourceType) { + + Assert.notNull(sourceType, "Source type must not be null!"); + + Map> result = new HashMap>(); + + for (Entry> entry : projectionDefinitions.entrySet()) { + if (entry.getKey().sourceType.equals(sourceType)) { + result.put(entry.getKey().name, entry.getValue()); + } + } + + return result; + } + /** * Value object to define lookup keys for projections. * diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/RepositoryRestConfiguration.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/RepositoryRestConfiguration.java index c0088b830..85ce70ea4 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/RepositoryRestConfiguration.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/RepositoryRestConfiguration.java @@ -45,23 +45,29 @@ public class RepositoryRestConfiguration { private ResourceMappingConfiguration domainMappings = new ResourceMappingConfiguration(); private ResourceMappingConfiguration repoMappings = new ResourceMappingConfiguration(); private final ProjectionDefinitionConfiguration projectionConfiguration; + private final MetadataConfiguration metadataConfiguration; /** * Creates a new default {@link RepositoryRestConfiguration}. */ public RepositoryRestConfiguration() { - this(new ProjectionDefinitionConfiguration()); + this(new ProjectionDefinitionConfiguration(), new MetadataConfiguration()); } /** * Creates a new {@link RepositoryRestConfiguration} with the given {@link ProjectionDefinitionConfiguration}. * * @param projectionConfiguration must not be {@literal null}. + * @param metadataConfiguration must not be {@literal null}. */ - public RepositoryRestConfiguration(ProjectionDefinitionConfiguration projectionConfiguration) { + public RepositoryRestConfiguration(ProjectionDefinitionConfiguration projectionConfiguration, + MetadataConfiguration metadataConfiguration) { Assert.notNull(projectionConfiguration, "ProjectionDefinitionConfiguration must not be null!"); + Assert.notNull(metadataConfiguration, "MetadataConfiguration must not be null!"); + this.projectionConfiguration = projectionConfiguration; + this.metadataConfiguration = metadataConfiguration; } /** @@ -76,14 +82,25 @@ public class RepositoryRestConfiguration { /** * The base URI against which the exporter should calculate its links. * - * @param baseUri The base URI. + * @param baseUri must not be {@literal null}. */ public RepositoryRestConfiguration setBaseUri(URI baseUri) { - Assert.notNull(baseUri, "The baseUri cannot be null."); + Assert.notNull(baseUri, "The base URI cannot be null."); this.baseUri = baseUri; return this; } + /** + * The base URI against which the exporter should calculate its links. + * + * @param baseUri must not be {@literal null}. + */ + public RepositoryRestConfiguration setBaseUri(String baseUri) { + Assert.notNull(baseUri, "The base URI cannot be null."); + this.baseUri = URI.create(baseUri); + return this; + } + /** * Get the default size of {@link org.springframework.data.domain.Pageable}s. Default is 20. * @@ -381,4 +398,13 @@ public class RepositoryRestConfiguration { public ProjectionDefinitionConfiguration projectionConfiguration() { return projectionConfiguration; } + + /** + * Returns the {@link MetadataConfiguration} to customize metadata exposure. + * + * @return + */ + public MetadataConfiguration metadataConfiguration() { + return metadataConfiguration; + } } diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/AnnotationBasedResourceDescription.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/AnnotationBasedResourceDescription.java index 5d2f9674f..b73d75cb7 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/AnnotationBasedResourceDescription.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/AnnotationBasedResourceDescription.java @@ -15,6 +15,7 @@ */ package org.springframework.data.rest.core.mapping; +import org.springframework.core.annotation.AnnotationUtils; import org.springframework.data.rest.core.annotation.Description; import org.springframework.http.MediaType; import org.springframework.util.Assert; @@ -26,7 +27,7 @@ import org.springframework.util.StringUtils; * * @author Oliver Gierke */ -class AnnotationBasedResourceDescription extends ResolvableResourceDescriptionSupport { +public class AnnotationBasedResourceDescription extends ResolvableResourceDescriptionSupport { private final String message; private final ResourceDescription fallback; @@ -37,7 +38,7 @@ class AnnotationBasedResourceDescription extends ResolvableResourceDescriptionSu * @param description must not be {@literal null}. * @param fallback must not be {@literal null}. */ - AnnotationBasedResourceDescription(Description description, ResourceDescription fallback) { + public AnnotationBasedResourceDescription(Description description, ResourceDescription fallback) { Assert.notNull(description, "Description must not be null!"); Assert.notNull(fallback, "Fallback resource description must not be null!"); @@ -46,9 +47,28 @@ class AnnotationBasedResourceDescription extends ResolvableResourceDescriptionSu this.fallback = fallback; } - /** - * @return the message + public AnnotationBasedResourceDescription(Class type, ResourceDescription fallback) { + + Description description = AnnotationUtils.findAnnotation(type, Description.class); + + this.message = description == null ? null : description.value(); + this.fallback = fallback; + } + + /* + * (non-Javadoc) + * @see org.springframework.context.MessageSourceResolvable#getCodes() */ + @Override + public String[] getCodes() { + return fallback.getCodes(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.rest.core.mapping.ResourceDescription#getMessage() + */ + @Override public String getMessage() { return StringUtils.hasText(message) ? message : fallback.getMessage(); } diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/MethodResourceMapping.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/MethodResourceMapping.java index c33242d87..fb8a66964 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/MethodResourceMapping.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/MethodResourceMapping.java @@ -16,7 +16,6 @@ package org.springframework.data.rest.core.mapping; import java.lang.reflect.Method; -import java.util.List; /** * A {@link ResourceMapping} that is backed by a {@link Method}. @@ -33,9 +32,9 @@ public interface MethodResourceMapping extends ResourceMapping { Method getMethod(); /** - * Returns the names of the parameters the method exposes. + * Returns {@link ParameterMetadata} instances for all named parameters. * * @return */ - List getParameterNames(); + ParametersMetadata getParametersMetadata(); } diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/ParameterMetadata.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/ParameterMetadata.java new file mode 100644 index 000000000..b675647ee --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/ParameterMetadata.java @@ -0,0 +1,103 @@ +/* + * Copyright 2014 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 + * + * http://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.data.rest.core.mapping; + +import org.springframework.core.MethodParameter; +import org.springframework.data.rest.core.annotation.Description; +import org.springframework.util.Assert; + +/** + * Value object to capture metadata for query method parameters. + * + * @author Oliver Gierke + */ +public final class ParameterMetadata { + + private final String name; + private final ResourceDescription description; + + /** + * Creates a new {@link ParameterMetadata} for the given {@link MethodParameter} and base rel. + * + * @param name must not be {@literal null} or empty. + * @param baseRel must not be {@literal null} or empty. + */ + public ParameterMetadata(MethodParameter parameter, String baseRel) { + + this.name = parameter.getParameterName(); + + Assert.hasText(name, "Parameter must not be null or empty!"); + Assert.hasText(baseRel, "Method rel must not be null!"); + + ResourceDescription fallback = TypedResourceDescription.defaultFor(baseRel.concat(".").concat(name), + parameter.getParameterType()); + Description annotation = parameter.getParameterAnnotation(Description.class); + + this.description = annotation == null ? fallback : new AnnotationBasedResourceDescription(annotation, fallback); + } + + /** + * Return sthe name of the method parameter. + * + * @return the name + */ + public String getName() { + return name; + } + + /** + * Returns the description for the method parameter. + * + * @return the description + */ + public ResourceDescription getDescription() { + return description; + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof ParameterMetadata)) { + return false; + } + + ParameterMetadata that = (ParameterMetadata) obj; + return this.name.equals(that.name) && this.description.equals(that.description); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + + int result = 17; + + result += 31 * name.hashCode(); + result += 31 * description.hashCode(); + + return result; + } +} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/ParametersMetadata.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/ParametersMetadata.java new file mode 100644 index 000000000..4951d7dc9 --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/ParametersMetadata.java @@ -0,0 +1,69 @@ +/* + * Copyright 2014 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 + * + * http://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.data.rest.core.mapping; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import org.springframework.util.Assert; + +/** + * Value object for a list of {@link ParameterMetadata} instances. + * + * @author Oliver Gierke + */ +public class ParametersMetadata implements Iterable { + + private final List parameterMetadata; + + /** + * Creates a new {@link ParametersMetadata} instance for the given {@link ParameterMetadata} instances. + * + * @param parameterMetadata must not be {@literal null}. + */ + ParametersMetadata(List parameterMetadata) { + + Assert.notNull(parameterMetadata, "Parameter metadata must not be null!"); + + this.parameterMetadata = parameterMetadata; + } + + /** + * Returns all parameter names. + * + * @return + */ + public List getParameterNames() { + + List names = new ArrayList(parameterMetadata.size()); + + for (ParameterMetadata metadata : parameterMetadata) { + names.add(metadata.getName()); + } + + return names; + } + + /* + * (non-Javadoc) + * @see java.lang.Iterable#iterator() + */ + @Override + public Iterator iterator() { + return parameterMetadata.iterator(); + } +} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/RepositoryMethodResourceMapping.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/RepositoryMethodResourceMapping.java index eb875f79b..c5b1e1340 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/RepositoryMethodResourceMapping.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/RepositoryMethodResourceMapping.java @@ -47,7 +47,7 @@ class RepositoryMethodResourceMapping implements MethodResourceMapping { private final Method method; private final boolean paging; - private final List parameterNames; + private final List parameterMetadata; /** * Creates a new {@link RepositoryMethodResourceMapping} for the given {@link Method}. @@ -61,24 +61,24 @@ class RepositoryMethodResourceMapping implements MethodResourceMapping { Assert.notNull(resourceMapping, "ResourceMapping must not be null!"); RestResource annotation = AnnotationUtils.findAnnotation(method, RestResource.class); + String resourceRel = resourceMapping.getRel(); this.isExported = annotation != null ? annotation.exported() : true; this.rel = annotation == null || !StringUtils.hasText(annotation.rel()) ? method.getName() : annotation.rel(); this.path = annotation == null || !StringUtils.hasText(annotation.path()) ? new Path(method.getName()) : new Path( annotation.path()); this.method = method; - this.parameterNames = discoverParameterNames(method); + this.parameterMetadata = discoverParameterMetadata(method, resourceRel.concat(".").concat(rel)); this.paging = Arrays.asList(method.getParameterTypes()).contains(Pageable.class); } - private static final List discoverParameterNames(Method method) { + private static final List discoverParameterMetadata(Method method, String baseRel) { - List result = new ArrayList(); + List result = new ArrayList(); for (MethodParameter parameter : new MethodParameters(method, PARAM_VALUE).getParameters()) { - String name = parameter.getParameterName(); - if (name != null) { - result.add(name); + if (StringUtils.hasText(parameter.getParameterName())) { + result.add(new ParameterMetadata(parameter, baseRel)); } } @@ -123,11 +123,11 @@ class RepositoryMethodResourceMapping implements MethodResourceMapping { /* * (non-Javadoc) - * @see org.springframework.data.rest.core.mapping.MethodResourceMapping#getParameterNames() + * @see org.springframework.data.rest.core.mapping.MethodResourceMapping#getParameterMetadata() */ @Override - public List getParameterNames() { - return parameterNames; + public ParametersMetadata getParametersMetadata() { + return new ParametersMetadata(parameterMetadata); } /* diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/RepositoryResourceMappings.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/RepositoryResourceMappings.java index 9a0cc31ce..44f8de13e 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/RepositoryResourceMappings.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/RepositoryResourceMappings.java @@ -210,7 +210,7 @@ public class RepositoryResourceMappings implements ResourceMappings { * (non-Javadoc) * @see org.springframework.data.rest.core.mapping.ResourceMetadataProvider#getMappingFor(org.springframework.data.mapping.PersistentProperty) */ - ResourceMapping getMappingFor(PersistentProperty property) { + public ResourceMapping getMappingFor(PersistentProperty property) { ResourceMapping propertyMapping = propertyCache.get(property); @@ -322,8 +322,8 @@ public class RepositoryResourceMappings implements ResourceMappings { @Override public ResourceDescription getDescription() { - ResourceDescription fallback = SimpleResourceDescription.defaultFor(property, - ownerTypeMapping.getItemResourceRel()); + ResourceDescription fallback = TypedResourceDescription.defaultFor(ownerTypeMapping.getItemResourceRel(), + property); if (description != null) { return new AnnotationBasedResourceDescription(description, fallback); diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/ResolvableResourceDescriptionSupport.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/ResolvableResourceDescriptionSupport.java index ac6816757..f26d99ef7 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/ResolvableResourceDescriptionSupport.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/ResolvableResourceDescriptionSupport.java @@ -24,15 +24,6 @@ import org.springframework.context.MessageSourceResolvable; */ public abstract class ResolvableResourceDescriptionSupport implements ResourceDescription { - /* - * (non-Javadoc) - * @see org.springframework.context.MessageSourceResolvable#getCodes() - */ - @Override - public String[] getCodes() { - return new String[] { getMessage() }; - } - /* * (non-Javadoc) * @see org.springframework.context.MessageSourceResolvable#getArguments() diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/SimpleResourceDescription.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/SimpleResourceDescription.java index 0fef09276..58f69e71b 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/SimpleResourceDescription.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/SimpleResourceDescription.java @@ -15,8 +15,8 @@ */ package org.springframework.data.rest.core.mapping; -import org.springframework.data.mapping.PersistentProperty; import org.springframework.http.MediaType; +import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** @@ -24,49 +24,61 @@ import org.springframework.util.StringUtils; */ public class SimpleResourceDescription extends ResolvableResourceDescriptionSupport { - private static final String DEFAULT_KEY_PREFIX = "rest.description"; - private static final MediaType DEFAULT_MEDIA_TYPE = MediaType.TEXT_PLAIN; + protected static final String DEFAULT_KEY_PREFIX = "rest.description"; + protected static final MediaType DEFAULT_MEDIA_TYPE = MediaType.TEXT_PLAIN; - private String message; - private MediaType type; + private final String message; + private final MediaType mediaType; + + /** + * Creates a new {@link SimpleResourceDescription} with the given message and {@link MediaType}. + * + * @param message must not be {@literal null} or empty. + * @param mediaType must not be {@literal null} or empty. + */ + protected SimpleResourceDescription(String message, MediaType mediaType) { + + Assert.hasText(message, "Message must not be null or empty!"); + Assert.notNull(mediaType, "MediaType must not be null!"); - private SimpleResourceDescription(String message, MediaType mediaType) { this.message = message; - this.type = mediaType; - } - - public static ResourceDescription defaultFor(PersistentProperty property, String rel) { - - String message = String.format("%s.%s.%s", DEFAULT_KEY_PREFIX, rel, property.getName()); - return new SimpleResourceDescription(message, DEFAULT_MEDIA_TYPE); + this.mediaType = mediaType; } public static ResourceDescription defaultFor(String rel) { - - String message = String.format("%s.%s", DEFAULT_KEY_PREFIX, rel); - return new SimpleResourceDescription(message, DEFAULT_MEDIA_TYPE); + return new SimpleResourceDescription(String.format("%s.%s", DEFAULT_KEY_PREFIX, rel), DEFAULT_MEDIA_TYPE); } - public static ResourceDescription defaultForCollection(Class type) { - return null; - } - - public static ResourceDescription defaultForMethod(RepositoryMethodResourceMapping mapping) { - return null; - } - - /** - * @return the message + /* + * (non-Javadoc) + * @see org.springframework.data.rest.core.mapping.ResourceDescription#getMessage() */ public String getMessage() { return message; } + /* + * (non-Javadoc) + * @see org.springframework.data.rest.core.mapping.ResourceDescription#getType() + */ public MediaType getType() { - return type; + return mediaType; } + /* + * (non-Javadoc) + * @see org.springframework.data.rest.core.mapping.ResourceDescription#isDefault() + */ public boolean isDefault() { return StringUtils.hasText(message) && message.startsWith(DEFAULT_KEY_PREFIX); } + + /* + * (non-Javadoc) + * @see org.springframework.context.MessageSourceResolvable#getCodes() + */ + @Override + public String[] getCodes() { + return new String[] { message }; + } } diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/TypeBasedCollectionResourceMapping.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/TypeBasedCollectionResourceMapping.java index d3972f686..89f6f02a3 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/TypeBasedCollectionResourceMapping.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/TypeBasedCollectionResourceMapping.java @@ -127,6 +127,14 @@ class TypeBasedCollectionResourceMapping implements CollectionResourceMapping { ResourceDescription fallback = SimpleResourceDescription.defaultFor(getRel()); + if (description != null) { + return new AnnotationBasedResourceDescription(description, fallback); + } + + if (annotation != null) { + return new AnnotationBasedResourceDescription(annotation.description(), fallback); + } + return fallback; } diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/TypedResourceDescription.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/TypedResourceDescription.java new file mode 100644 index 000000000..1e22a3685 --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/TypedResourceDescription.java @@ -0,0 +1,82 @@ +/* + * Copyright 2014 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 + * + * http://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.data.rest.core.mapping; + +import java.util.Arrays; + +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.http.MediaType; +import org.springframework.util.StringUtils; + +/** + * {@link SimpleResourceDescription} that additionally captures a type to be able to potentially create a reasonable + * default message. The implementation will do so for enum types by rendering the available values as default message + * and also provide them as arguments for message resolution. + * + * @author Oliver Gierke + */ +public class TypedResourceDescription extends SimpleResourceDescription { + + private final Class type; + + /** + * Creates a new {@link TypedResourceDescription} for the given message, {@link MediaType} and type. + * + * @param message must not be {@literal null} or empty. + * @param mediaType must not be {@literal null} or empty. + * @param type can be {@literal null}, defaults to {@link Object}. + */ + private TypedResourceDescription(String message, MediaType mediaType, Class type) { + + super(message, mediaType); + + this.type = type == null ? Object.class : type; + } + + public static ResourceDescription defaultFor(String rel, PersistentProperty property) { + + String message = String.format("%s.%s.%s", DEFAULT_KEY_PREFIX, rel, property.getName()); + return new TypedResourceDescription(message, DEFAULT_MEDIA_TYPE, property.getType()); + } + + public static ResourceDescription defaultFor(String rel, Class type) { + + String message = String.format("%s.%s", DEFAULT_KEY_PREFIX, rel); + return new TypedResourceDescription(message, DEFAULT_MEDIA_TYPE, type); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.rest.core.mapping.ResolvableResourceDescriptionSupport#getArguments() + */ + @Override + public Object[] getArguments() { + return type.isEnum() ? new Object[] { getEnumValues(type) } : new Object[0]; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.rest.core.mapping.ResolvableResourceDescriptionSupport#getDefaultMessage() + */ + @Override + public String getDefaultMessage() { + return type.isEnum() ? getEnumValues(type) : null; + } + + private String getEnumValues(Class type) { + return StringUtils.collectionToDelimitedString(Arrays.asList(type.getEnumConstants()), ", "); + } +} diff --git a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/mapping/RepositoryMethodResourceMappingUnitTests.java b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/mapping/RepositoryMethodResourceMappingUnitTests.java index 9837fdad1..32535b028 100644 --- a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/mapping/RepositoryMethodResourceMappingUnitTests.java +++ b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/mapping/RepositoryMethodResourceMappingUnitTests.java @@ -67,7 +67,7 @@ public class RepositoryMethodResourceMappingUnitTests { Method method = PersonRepository.class.getMethod("findByLastname", String.class); MethodResourceMapping mapping = new RepositoryMethodResourceMapping(method, resourceMapping); - assertThat(mapping.getParameterNames(), is(emptyIterable())); + assertThat(mapping.getParametersMetadata().getParameterNames(), is(emptyIterable())); } /** @@ -79,8 +79,8 @@ public class RepositoryMethodResourceMappingUnitTests { Method method = PersonRepository.class.getMethod("findByFirstname", String.class); MethodResourceMapping mapping = new RepositoryMethodResourceMapping(method, resourceMapping); - assertThat(mapping.getParameterNames(), hasSize(1)); - assertThat(mapping.getParameterNames(), hasItem("firstname")); + assertThat(mapping.getParametersMetadata().getParameterNames(), hasSize(1)); + assertThat(mapping.getParametersMetadata().getParameterNames(), hasItem("firstname")); } /** diff --git a/spring-data-rest-webmvc/pom.xml b/spring-data-rest-webmvc/pom.xml index 338da2ea9..3b0b58808 100644 --- a/spring-data-rest-webmvc/pom.xml +++ b/spring-data-rest-webmvc/pom.xml @@ -39,21 +39,6 @@ provided - - - - org.springframework - spring-orm - true - - - - org.hibernate.javax.persistence - hibernate-jpa-2.0-api - 1.0.1.Final - true - - @@ -101,6 +86,21 @@ 1.7 + + + + org.springframework + spring-orm + true + + + + org.hibernate.javax.persistence + hibernate-jpa-2.0-api + 1.0.1.Final + true + + diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/BaseUri.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/BaseUri.java index 0a3205653..145409b00 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/BaseUri.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/BaseUri.java @@ -26,6 +26,7 @@ import javax.servlet.http.HttpServletRequest; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UrlPathHelper; @@ -54,6 +55,15 @@ public class BaseUri { this.baseUri = URI.create(trimTrailingCharacter(trimTrailingCharacter(uriString, '/'), '/')); } + /** + * Creates a new {@link BaseUri} with the given URI as base. + * + * @param uri must not be {@literal null}. + */ + public BaseUri(String uri) { + this(URI.create(uri)); + } + /** * Returns the base URI. * @@ -131,4 +141,19 @@ public class BaseUri { return null; } + + /** + * Returns a new {@link UriComponentsBuilder} for the base URI. If the base URI is not absolute, it'll lokup the URI + * for the current servlet mapping and extend it accordingly. + * + * @return + */ + public UriComponentsBuilder getUriComponentsBuilder() { + + if (baseUri.isAbsolute()) { + return UriComponentsBuilder.fromUri(baseUri); + } + + return ServletUriComponentsBuilder.fromCurrentServletMapping().path(baseUri.toString()); + } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySearchController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySearchController.java index ccb3dbb52..520242a0d 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySearchController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySearchController.java @@ -19,7 +19,6 @@ import static org.springframework.data.rest.webmvc.ControllerUtils.*; import java.lang.reflect.Method; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; @@ -28,6 +27,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Pageable; import org.springframework.data.rest.core.invoke.RepositoryInvoker; import org.springframework.data.rest.core.mapping.MethodResourceMapping; +import org.springframework.data.rest.core.mapping.ParameterMetadata; import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.data.rest.core.mapping.SearchResourceMappings; import org.springframework.data.web.PagedResourcesAssembler; @@ -38,6 +38,9 @@ import org.springframework.hateoas.Links; import org.springframework.hateoas.Resource; import org.springframework.hateoas.ResourceSupport; import org.springframework.hateoas.Resources; +import org.springframework.hateoas.TemplateVariable; +import org.springframework.hateoas.TemplateVariable.VariableType; +import org.springframework.hateoas.TemplateVariables; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -45,7 +48,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; -import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -63,7 +65,6 @@ class RepositorySearchController extends AbstractRepositoryRestController { private static final String SEARCH = "/search"; private static final String BASE_MAPPING = "/{repository}" + SEARCH; - private static final String PARAMETER_NAME_TEMPALTE_PATTERN = "{?%s}"; private final EntityLinks entityLinks; private final ResourceMappings mappings; @@ -302,8 +303,13 @@ class RepositorySearchController extends AbstractRepositoryRestController { continue; } - String parameterTemplateVariable = getParameterTemplateVariable(mapping.getParameterNames()); - String href = builder.slash(mapping.getPath()).toString().concat(parameterTemplateVariable); + TemplateVariables variables = new TemplateVariables(); + + for (ParameterMetadata metadata : mapping.getParametersMetadata()) { + variables = variables.concat(new TemplateVariable(metadata.getName(), VariableType.REQUEST_PARAM)); + } + + String href = builder.slash(mapping.getPath()).toString().concat(variables.toString()); Link link = new Link(href, mapping.getRel()); @@ -317,11 +323,6 @@ class RepositorySearchController extends AbstractRepositoryRestController { return new Links(links); } - private static String getParameterTemplateVariable(Collection parameters) { - String parameterString = StringUtils.collectionToCommaDelimitedString(parameters); - return parameters.isEmpty() ? "" : String.format(PARAMETER_NAME_TEMPALTE_PATTERN, parameterString); - } - /** * Verifies that the given {@link RootResourceInformation} has searches exposed. * diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsController.java new file mode 100644 index 000000000..591ea4009 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsController.java @@ -0,0 +1,149 @@ +/* + * Copyright 2014 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 + * + * http://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.data.rest.webmvc.alps; + +import static org.springframework.web.bind.annotation.RequestMethod.*; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.repository.support.Repositories; +import org.springframework.data.rest.core.config.RepositoryRestConfiguration; +import org.springframework.data.rest.core.mapping.ResourceMappings; +import org.springframework.data.rest.core.mapping.ResourceMetadata; +import org.springframework.data.rest.webmvc.BaseUri; +import org.springframework.data.rest.webmvc.RepositoryRestController; +import org.springframework.data.rest.webmvc.ResourceNotFoundException; +import org.springframework.data.rest.webmvc.RootResourceInformation; +import org.springframework.hateoas.alps.Alps; +import org.springframework.hateoas.alps.Descriptor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Controller exposing semantic documentation for the resources exposed using the Application Level Profile Semantics + * format. + * + * @author Oliver Gierke + * @see http://alps.io + */ +@RepositoryRestController +@RequestMapping(AlpsController.ALPS_ROOT_MAPPING) +public class AlpsController { + + static final String ALPS_ROOT_MAPPING = "/alps"; + + private final Repositories repositories; + private final ResourceMappings mappings; + private final RepositoryRestConfiguration configuration; + + /** + * Creates a new {@link AlpsController} for the given {@link Repositories}, + * {@link RootResourceInformationToAlpsDescriptorConverter} and {@link ResourceMappings}. + * + * @param repositories must not be {@literal null}. + * @param mappings must not be {@literal null}. + * @param configuration must not be {@literal null}. + */ + @Autowired + public AlpsController(Repositories repositories, ResourceMappings mappings, RepositoryRestConfiguration configuration) { + + Assert.notNull(repositories, "Repositories must not be null!"); + Assert.notNull(mappings, "ResourceMappings must not be null!"); + Assert.notNull(configuration, "MetadataConfiguration must not be null!"); + + this.repositories = repositories; + this.mappings = mappings; + this.configuration = configuration; + } + + /** + * Exposes the allowed HTTP methods for the ALPS resources. + * + * @return + */ + @RequestMapping(value = { "", "/{repository}" }, method = OPTIONS) + HttpEntity alpsOptions() { + + verifyAlpsEnabled(); + + HttpHeaders headers = new HttpHeaders(); + headers.setAllow(Collections.singleton(HttpMethod.GET)); + + return new ResponseEntity(headers, HttpStatus.OK); + } + + /** + * Exposes a resource to contain descriptors pointing to the discriptors for individual resources. + * + * @return + */ + @RequestMapping(method = GET) + HttpEntity alps() { + + verifyAlpsEnabled(); + + List descriptors = new ArrayList(); + + for (Class domainType : repositories) { + + ResourceMetadata mapping = mappings.getMappingFor(domainType); + + if (mapping.isExported()) { + + BaseUri baseUri = new BaseUri(configuration.getBaseUri()); + UriComponentsBuilder builder = baseUri.getUriComponentsBuilder().path(ALPS_ROOT_MAPPING); + String href = builder.path(mapping.getPath().toString()).build().toUriString(); + descriptors.add(Alps.descriptor().name(mapping.getRel()).href(href).build()); + } + } + + Alps alps = Alps.alps().// + descriptors(descriptors).// + build(); + + return new ResponseEntity(alps, HttpStatus.OK); + } + + /** + * Exposes an ALPS resource to describe an individual repository resource. + * + * @param information + * @return + */ + @RequestMapping(value = "/{repository}", method = GET) + HttpEntity descriptor(RootResourceInformation information) { + + verifyAlpsEnabled(); + + return new ResponseEntity(information, HttpStatus.OK); + } + + private void verifyAlpsEnabled() { + + if (!configuration.metadataConfiguration().alpsEnabled()) { + throw new ResourceNotFoundException(); + } + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsJsonHttpMessageConverter.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsJsonHttpMessageConverter.java new file mode 100644 index 000000000..b53ba8440 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsJsonHttpMessageConverter.java @@ -0,0 +1,97 @@ +/* + * Copyright 2014 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 + * + * http://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.data.rest.webmvc.alps; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.Arrays; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.rest.webmvc.RootResourceInformation; +import org.springframework.hateoas.alps.Alps; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.util.Assert; + +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * {@link HttpMessageConverter} to render {@link Alps} and {@link RootResourceInformation} instances as + * {@code application/alps+json}. + * + * @author Oliver Gierke + */ +public class AlpsJsonHttpMessageConverter extends MappingJackson2HttpMessageConverter { + + private static final MediaType ALPS_MEDIA_TYPE = MediaType.parseMediaType("application/alps+json"); + + private final RootResourceInformationToAlpsDescriptorConverter converter; + + /** + * Creates a new {@link AlpsJsonHttpMessageConverter} for the given {@link Converter}. + * + * @param converter must not be {@literal null}. + */ + public AlpsJsonHttpMessageConverter(RootResourceInformationToAlpsDescriptorConverter converter) { + + Assert.notNull(converter, "Converter must not be null!"); + + this.converter = converter; + + ObjectMapper mapper = getObjectMapper(); + mapper.setSerializationInclusion(Include.NON_EMPTY); + + setPrettyPrint(true); + setSupportedMediaTypes(Arrays.asList(ALPS_MEDIA_TYPE, MediaType.APPLICATION_JSON, MediaType.ALL)); + } + + /* + * (non-Javadoc) + * @see org.springframework.http.converter.json.MappingJackson2HttpMessageConverter#canWrite(java.lang.Class, org.springframework.http.MediaType) + */ + @Override + public boolean canWrite(Class clazz, MediaType mediaType) { + return (clazz.isAssignableFrom(Alps.class) || clazz.isAssignableFrom(RootResourceInformation.class)) + && super.canWrite(clazz, mediaType); + } + + /* + * (non-Javadoc) + * @see org.springframework.http.converter.json.MappingJackson2HttpMessageConverter#canRead(java.lang.reflect.Type, java.lang.Class, org.springframework.http.MediaType) + */ + @Override + public boolean canRead(Type type, Class contextClass, MediaType mediaType) { + return false; + } + + /* + * (non-Javadoc) + * @see org.springframework.http.converter.json.MappingJackson2HttpMessageConverter#writeInternal(java.lang.Object, org.springframework.http.HttpOutputMessage) + */ + @Override + protected void writeInternal(Object object, HttpOutputMessage outputMessage) throws IOException, + HttpMessageNotWritableException { + + Object toWrite = object instanceof RootResourceInformation ? converter.convert((RootResourceInformation) object) + : object; + + super.writeInternal(toWrite, outputMessage); + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsResourceProcessor.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsResourceProcessor.java new file mode 100644 index 000000000..9636716ce --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsResourceProcessor.java @@ -0,0 +1,68 @@ +/* + * Copyright 2014 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 + * + * http://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.data.rest.webmvc.alps; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.rest.core.config.RepositoryRestConfiguration; +import org.springframework.data.rest.webmvc.BaseUri; +import org.springframework.data.rest.webmvc.RepositoryLinksResource; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.ResourceProcessor; +import org.springframework.util.Assert; + +/** + * {@link ResourceProcessor} to add a {@code profile} link to the root resource to point to the ALPS resources in case + * the support for ALPS is activated. + * + * @author Oliver Gierke + */ +public class AlpsResourceProcessor implements ResourceProcessor { + + private static final String PROFILE_REL = "profile"; + + private final RepositoryRestConfiguration configuration; + + /** + * Creates a new {@link AlpsResourceProcessor} with the given {@link RepositoryRestConfiguration}. + * + * @param configuration must not be {@literal null}. + */ + @Autowired + public AlpsResourceProcessor(RepositoryRestConfiguration configuration) { + + Assert.notNull(configuration, "RepositoryRestConfiguration must not be null!"); + + this.configuration = configuration; + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.ResourceProcessor#process(org.springframework.hateoas.ResourceSupport) + */ + @Override + public RepositoryLinksResource process(RepositoryLinksResource resource) { + + if (configuration.metadataConfiguration().alpsEnabled()) { + + BaseUri baseUri = new BaseUri(configuration.getBaseUri()); + String href = baseUri.getUriComponentsBuilder().path(AlpsController.ALPS_ROOT_MAPPING).build().toString(); + + resource.add(new Link(href, PROFILE_REL)); + } + + return resource; + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/RootResourceInformationToAlpsDescriptorConverter.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/RootResourceInformationToAlpsDescriptorConverter.java new file mode 100644 index 000000000..586398442 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/RootResourceInformationToAlpsDescriptorConverter.java @@ -0,0 +1,453 @@ +/* + * Copyright 2014 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 + * + * http://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.data.rest.webmvc.alps; + +import static org.springframework.hateoas.alps.Alps.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.data.mapping.Association; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.SimpleAssociationHandler; +import org.springframework.data.mapping.SimplePropertyHandler; +import org.springframework.data.mapping.context.PersistentEntities; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.support.Repositories; +import org.springframework.data.rest.core.annotation.Description; +import org.springframework.data.rest.core.config.ProjectionDefinitionConfiguration; +import org.springframework.data.rest.core.config.RepositoryRestConfiguration; +import org.springframework.data.rest.core.mapping.AnnotationBasedResourceDescription; +import org.springframework.data.rest.core.mapping.MethodResourceMapping; +import org.springframework.data.rest.core.mapping.ParameterMetadata; +import org.springframework.data.rest.core.mapping.ResourceDescription; +import org.springframework.data.rest.core.mapping.ResourceMapping; +import org.springframework.data.rest.core.mapping.ResourceMappings; +import org.springframework.data.rest.core.mapping.ResourceMetadata; +import org.springframework.data.rest.core.mapping.SimpleResourceDescription; +import org.springframework.data.rest.webmvc.ResourceType; +import org.springframework.data.rest.webmvc.RootResourceInformation; +import org.springframework.data.rest.webmvc.json.JacksonMetadata; +import org.springframework.data.rest.webmvc.mapping.AssociationLinks; +import org.springframework.data.rest.webmvc.mapping.PropertyMappings; +import org.springframework.hateoas.EntityLinks; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.TemplateVariable; +import org.springframework.hateoas.alps.Alps; +import org.springframework.hateoas.alps.Descriptor; +import org.springframework.hateoas.alps.Descriptor.DescriptorBuilder; +import org.springframework.hateoas.alps.Doc; +import org.springframework.hateoas.alps.Format; +import org.springframework.hateoas.alps.Type; +import org.springframework.hateoas.mvc.ControllerLinkBuilder; +import org.springframework.http.HttpMethod; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.introspect.AnnotatedMethod; +import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; + +/** + * Converter to create Alps {@link Descriptor} instances for a {@link RootResourceInformation}. + * + * @author Oliver Gierke + */ +public class RootResourceInformationToAlpsDescriptorConverter { + + private static final List UNDOCUMENTED_METHODS = Arrays.asList(HttpMethod.OPTIONS, HttpMethod.HEAD); + + private final Repositories repositories; + private final PersistentEntities persistentEntities; + private final ResourceMappings mappings; + private final EntityLinks entityLinks; + private final MessageSourceAccessor messageSource; + private final RepositoryRestConfiguration configuration; + private final ObjectMapper mapper; + + /** + * Creates a new {@link RootResourceInformationToAlpsDescriptorConverter} instance. + * + * @param mappings must not be {@literal null}. + * @param repositories must not be {@literal null}. + * @param entities must not be {@literal null}. + * @param entityLinks must not be {@literal null}. + * @param messageSource must not be {@literal null}. + * @param configuration must not be {@literal null}. + * @param mapper must not be {@literal null}. + */ + public RootResourceInformationToAlpsDescriptorConverter(ResourceMappings mappings, Repositories repositories, + PersistentEntities entities, EntityLinks entityLinks, MessageSourceAccessor messageSource, + RepositoryRestConfiguration configuration, ObjectMapper mapper) { + + this.mappings = mappings; + this.persistentEntities = entities; + this.repositories = repositories; + this.entityLinks = entityLinks; + this.messageSource = messageSource; + this.configuration = configuration; + this.mapper = mapper; + } + + /* + * (non-Javadoc) + * @see org.springframework.core.convert.converter.Converter#convert(java.lang.Object) + */ + public Alps convert(RootResourceInformation resourceInformation) { + + Class type = resourceInformation.getDomainType(); + List descriptors = new ArrayList(); + + Descriptor representationDescriptor = buildRepresentationDescriptor(type); + + descriptors.add(representationDescriptor); + + for (HttpMethod method : resourceInformation.getSupportedMethods(ResourceType.COLLECTION)) { + + if (!UNDOCUMENTED_METHODS.contains(method)) { + descriptors.add(buildCollectionResourceDescriptor(type, resourceInformation, representationDescriptor, method)); + } + } + + for (HttpMethod method : resourceInformation.getSupportedMethods(ResourceType.ITEM)) { + + if (!UNDOCUMENTED_METHODS.contains(method)) { + descriptors.add(buildItemResourceDescriptor(resourceInformation, representationDescriptor, method)); + } + } + + descriptors.addAll(buildSearchResourceDescriptors(resourceInformation.getPersistentEntity())); + + return Alps.alps().descriptors(descriptors).build(); + } + + private Descriptor buildRepresentationDescriptor(Class type) { + + ResourceMetadata metadata = mappings.getMappingFor(type); + + return descriptor().// + id(metadata.getItemResourceRel().concat("-representation")).// + doc(getDocFor(metadata.getItemResourceDescription())).// + descriptors(buildPropertyDescriptors(type, metadata.getItemResourceRel())).// + build(); + } + + private Descriptor buildCollectionResourceDescriptor(Class type, RootResourceInformation resourceInformation, + Descriptor representationDescriptor, HttpMethod method) { + + ResourceMetadata metadata = mappings.getMappingFor(type); + + List nestedDescriptors = new ArrayList(); + nestedDescriptors.addAll(getPaginationDescriptors(type, method)); + nestedDescriptors.addAll(getProjectionDescriptor(type, method)); + + Type descriptorType = getType(method); + return descriptor().// + id(prefix(method).concat(metadata.getRel())).// + name(metadata.getRel()).// + type(descriptorType).// + doc(getDocFor(metadata.getDescription())).// + rt("#" + representationDescriptor.getId()).// + descriptors(nestedDescriptors).build(); + } + + /** + * Builds a descriptor for the projection parameter of the given resource. + * + * @param metadata + * @param projectionConfiguration + * @return + */ + private Descriptor buildProjectionDescriptor(ResourceMetadata metadata) { + + ProjectionDefinitionConfiguration projectionConfiguration = configuration.projectionConfiguration(); + String projectionParameterName = projectionConfiguration.getParameterName(); + + Map> projections = projectionConfiguration.getProjectionsFor(metadata.getDomainType()); + List projectionDescriptors = new ArrayList(projections.size()); + + for (Entry> projection : projections.entrySet()) { + + Class type = projection.getValue(); + String key = String.format("%s.%s.%s", metadata.getRel(), projectionParameterName, projection.getKey()); + ResourceDescription fallback = SimpleResourceDescription.defaultFor(key); + AnnotationBasedResourceDescription projectionDescription = new AnnotationBasedResourceDescription(type, fallback); + + projectionDescriptors.add(// + descriptor().// + type(Type.SEMANTIC).// + name(projection.getKey()).// + doc(getDocFor(projectionDescription)).// + descriptors(createJacksonDescriptor(projection.getKey(), type)).// + build()); + } + + return descriptor().// + type(Type.SEMANTIC).// + name(projectionParameterName).// + doc(getDocFor(SimpleResourceDescription.defaultFor(projectionParameterName))).// + descriptors(projectionDescriptors).build(); + } + + private List createJacksonDescriptor(String name, Class type) { + + List descriptors = new ArrayList(); + + for (BeanPropertyDefinition definition : new JacksonMetadata(mapper, type)) { + + AnnotatedMethod getter = definition.getGetter(); + Description description = getter.getAnnotation(Description.class); + ResourceDescription fallback = SimpleResourceDescription.defaultFor(String.format("%s.%s", name, + definition.getName())); + ResourceDescription resourceDescription = description == null ? null : new AnnotationBasedResourceDescription( + description, fallback); + + descriptors.add(// + descriptor().// + name(definition.getName()).// + type(Type.SEMANTIC).// + doc(getDocFor(resourceDescription)).// + build()); + } + + return descriptors; + } + + private Descriptor buildItemResourceDescriptor(RootResourceInformation resourceInformation, + Descriptor representationDescriptor, HttpMethod method) { + + PersistentEntity entity = resourceInformation.getPersistentEntity(); + ResourceMetadata metadata = mappings.getMappingFor(entity.getType()); + + return descriptor().// + id(prefix(method).concat(metadata.getItemResourceRel())).// + name(metadata.getItemResourceRel()).// + type(getType(method)).// + doc(getDocFor(metadata.getItemResourceDescription())).// + rt("#".concat(representationDescriptor.getId())). // + descriptors(getProjectionDescriptor(entity.getType(), method)).// + build(); + } + + private List getProjectionDescriptor(Class type, HttpMethod method) { + + if (!Type.SAFE.equals(getType(method))) { + return Collections.emptyList(); + } + + ProjectionDefinitionConfiguration projectionConfiguration = configuration.projectionConfiguration(); + + return projectionConfiguration.hasProjectionFor(type) ? Arrays.asList(buildProjectionDescriptor(mappings + .getMappingFor(type))) : Collections. emptyList(); + } + + /** + * Creates the {@link Descriptor}s for pagination parameters. + * + * @param type + * @return + */ + private List getPaginationDescriptors(Class type, HttpMethod method) { + + RepositoryInformation information = repositories.getRepositoryInformationFor(type); + + if (!information.isPagingRepository() || !getType(method).equals(Type.SAFE)) { + return Collections.emptyList(); + } + + Link linkToCollectionResource = entityLinks.linkToCollectionResource(type); + List variables = linkToCollectionResource.getVariables(); + List descriptors = new ArrayList(variables.size()); + + ProjectionDefinitionConfiguration projectionConfiguration = configuration.projectionConfiguration(); + + for (TemplateVariable variable : variables) { + + // Skip projection parameter + if (projectionConfiguration.getParameterName().equals(variable.getName())) { + continue; + } + + ResourceDescription description = SimpleResourceDescription.defaultFor(variable.getDescription()); + + descriptors.add(// + descriptor().// + name(variable.getName()).// + type(Type.SEMANTIC).// + doc(getDocFor(description)).// + build()); + } + + return descriptors; + } + + private List buildPropertyDescriptors(Class type, final String baseRel) { + + PersistentEntity entity = persistentEntities.getPersistentEntity(type); + final List propertyDescriptors = new ArrayList(); + final JacksonMetadata jackson = new JacksonMetadata(mapper, type); + final PropertyMappings propertyMappings = new PropertyMappings(mappings); + final AssociationLinks associationLinks = new AssociationLinks(mappings); + + entity.doWithProperties(new SimplePropertyHandler() { + + @Override + public void doWithPersistentProperty(PersistentProperty property) { + + BeanPropertyDefinition propertyDefinition = jackson.getDefinitionFor(property); + ResourceMapping propertyMapping = propertyMappings.getMappingFor(property); + + if (propertyDefinition != null) { + propertyDescriptors.add(// + descriptor(). // + type(Type.SEMANTIC).// + name(propertyDefinition.getName()).// + doc(getDocFor(propertyMapping.getDescription())).// + build()); + } + } + }); + + entity.doWithAssociations(new SimpleAssociationHandler() { + + @Override + public void doWithAssociation(Association> association) { + + PersistentProperty property = association.getInverse(); + ResourceMapping mapping = propertyMappings.getMappingFor(property); + + DescriptorBuilder builder = descriptor().// + name(mapping.getRel()).doc(getDocFor(mapping.getDescription())); + + if (associationLinks.isLinkableAssociation(property)) { + + ResourceMetadata targetTypeMapping = mappings.getMappingFor(property.getActualType()); + String localPath = targetTypeMapping.getRel().concat("#").concat(targetTypeMapping.getItemResourceRel()); + Link link = ControllerLinkBuilder.linkTo(AlpsController.class).slash(localPath).withSelfRel(); + + builder.// + type(Type.SAFE).// + rt(link.getHref()); + + } else { + + List nestedDescriptors = buildPropertyDescriptors(property.getActualType(), baseRel.concat(".") + .concat(mapping.getRel())); + + builder = builder.// + type(Type.SEMANTIC).// + descriptors(nestedDescriptors); + } + + propertyDescriptors.add(builder.build()); + } + }); + + return propertyDescriptors; + } + + private Collection buildSearchResourceDescriptors(PersistentEntity entity) { + + ResourceMetadata metadata = mappings.getMappingFor(entity.getType()); + List descriptors = new ArrayList(); + + for (MethodResourceMapping methodMapping : metadata.getSearchResourceMappings()) { + + List parameterDescriptors = new ArrayList(); + + for (ParameterMetadata parameterMetadata : methodMapping.getParametersMetadata()) { + + parameterDescriptors.add(// + descriptor().// + name(parameterMetadata.getName()).// + doc(getDocFor(parameterMetadata.getDescription())).// + type(Type.SEMANTIC)// + .build()); + } + + descriptors.add(descriptor().// + type(Type.SAFE).// + name(methodMapping.getRel()).// + descriptors(parameterDescriptors).// + build()); + } + + return descriptors; + } + + private Doc getDocFor(ResourceDescription description) { + + if (description == null) { + return null; + } + + String message = resolveMessage(description); + return message == null ? null : new Doc(message, Format.TEXT); + } + + private String resolveMessage(ResourceDescription description) { + + if (!description.isDefault()) { + return description.getMessage(); + } + + try { + return messageSource.getMessage(description); + } catch (NoSuchMessageException o_O) { + return configuration.metadataConfiguration().omitUnresolvableDescriptionKeys() ? null : description.getMessage(); + } + } + + private static String prefix(HttpMethod method) { + + switch (method) { + case GET: + return "get-"; + case POST: + return "create-"; + case DELETE: + return "delete-"; + case PUT: + return "update-"; + case PATCH: + return "patch-"; + default: + throw new IllegalArgumentException(method.name()); + } + } + + private static Type getType(HttpMethod method) { + + switch (method) { + case GET: + return Type.SAFE; + case PUT: + case DELETE: + return Type.IDEMPOTENT; + case POST: + case PATCH: + return Type.UNSAFE; + default: + return null; + } + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java index ae0d39e33..f1d0da8fa 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java @@ -23,11 +23,13 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import org.springframework.beans.factory.BeanCreationException; import org.springframework.beans.factory.BeanFactoryUtils; import org.springframework.beans.factory.ListableBeanFactory; import org.springframework.beans.factory.ObjectFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.config.PropertiesFactoryBean; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; @@ -39,6 +41,7 @@ import org.springframework.context.support.MessageSourceAccessor; import org.springframework.context.support.ReloadableResourceBundleMessageSource; import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.core.convert.support.ConfigurableConversionService; +import org.springframework.core.io.ClassPathResource; import org.springframework.data.domain.PageRequest; import org.springframework.data.geo.Distance; import org.springframework.data.geo.GeoModule; @@ -48,6 +51,7 @@ import org.springframework.data.mapping.context.PersistentEntities; import org.springframework.data.repository.support.DomainClassConverter; import org.springframework.data.repository.support.Repositories; import org.springframework.data.rest.core.UriToEntityConverter; +import org.springframework.data.rest.core.config.MetadataConfiguration; import org.springframework.data.rest.core.config.Projection; import org.springframework.data.rest.core.config.ProjectionDefinitionConfiguration; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; @@ -68,6 +72,9 @@ import org.springframework.data.rest.webmvc.RepositoryRestHandlerAdapter; import org.springframework.data.rest.webmvc.RepositoryRestHandlerMapping; import org.springframework.data.rest.webmvc.RestMediaTypes; import org.springframework.data.rest.webmvc.ServerHttpRequestMethodArgumentResolver; +import org.springframework.data.rest.webmvc.alps.AlpsJsonHttpMessageConverter; +import org.springframework.data.rest.webmvc.alps.AlpsResourceProcessor; +import org.springframework.data.rest.webmvc.alps.RootResourceInformationToAlpsDescriptorConverter; import org.springframework.data.rest.webmvc.convert.StringToDistanceConverter; import org.springframework.data.rest.webmvc.convert.StringToPointConverter; import org.springframework.data.rest.webmvc.convert.UriListHttpMessageConverter; @@ -225,11 +232,16 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon configuration.addProjection(projection); } - RepositoryRestConfiguration config = new RepositoryRestConfiguration(configuration); + RepositoryRestConfiguration config = new RepositoryRestConfiguration(configuration, metadataConfiguration()); configureRepositoryRestConfiguration(config); return config; } + @Bean + public MetadataConfiguration metadataConfiguration() { + return new MetadataConfiguration(); + } + @Bean public BaseUri baseUri() { return new BaseUri(config().getBaseUri()); @@ -337,11 +349,21 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon @Bean public MessageSourceAccessor resourceDescriptionMessageSourceAccessor() { - ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); - messageSource.setBasename("classpath:rest-messages"); - messageSource.setUseCodeAsDefaultMessage(true); + try { - return new MessageSourceAccessor(messageSource); + PropertiesFactoryBean propertiesFactoryBean = new PropertiesFactoryBean(); + propertiesFactoryBean.setLocation(new ClassPathResource("rest-default-messages.properties")); + propertiesFactoryBean.afterPropertiesSet(); + + ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); + messageSource.setBasename("classpath:rest-messages"); + messageSource.setCommonMessages(propertiesFactoryBean.getObject()); + + return new MessageSourceAccessor(messageSource); + + } catch (Exception o_O) { + throw new BeanCreationException("resourceDescriptionMessageSourceAccessor", "", o_O); + } } /** @@ -431,7 +453,6 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon * @return */ @Bean - @SuppressWarnings("rawtypes") public RequestMappingHandlerAdapter repositoryExporterHandlerAdapter() { List> messageConverters = defaultMessageConverters(); @@ -468,6 +489,11 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon return mapping; } + @Bean + public RequestMappingHandlerMapping fallbackMapping() { + return new RequestMappingHandlerMapping(); + } + @Bean public ResourceMappings resourceMappings() { @@ -517,6 +543,10 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon List> messageConverters = new ArrayList>(); + if (config().metadataConfiguration().alpsEnabled()) { + messageConverters.add(new AlpsJsonHttpMessageConverter(alpsConverter())); + } + if (config().getDefaultMediaType().equals(MediaTypes.HAL_JSON)) { messageConverters.add(halJacksonHttpMessageConverter()); messageConverters.add(jacksonHttpMessageConverter()); @@ -524,6 +554,7 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon messageConverters.add(jacksonHttpMessageConverter()); messageConverters.add(halJacksonHttpMessageConverter()); } + messageConverters.add(uriListHttpMessageConverter()); return messageConverters; @@ -620,6 +651,29 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon return new AnnotatedTypeScanner(Projection.class).findTypes(packagesToScan); } + // + // ALPS support + // + + @Bean + public RootResourceInformationToAlpsDescriptorConverter alpsConverter() { + + Repositories repositories = repositories(); + PersistentEntities persistentEntities = persistentEntities(); + RepositoryEntityLinks entityLinks = entityLinks(); + MessageSourceAccessor messageSourceAccessor = resourceDescriptionMessageSourceAccessor(); + RepositoryRestConfiguration config = config(); + ResourceMappings resourceMappings = resourceMappings(); + + return new RootResourceInformationToAlpsDescriptorConverter(resourceMappings, repositories, persistentEntities, + entityLinks, messageSourceAccessor, config, objectMapper()); + } + + @Bean + public AlpsResourceProcessor alpsResourceProcessor() { + return new AlpsResourceProcessor(config()); + } + /** * Override this method to add additional configuration. * diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JacksonMetadata.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JacksonMetadata.java new file mode 100644 index 000000000..3ee0689ee --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JacksonMetadata.java @@ -0,0 +1,85 @@ +/* + * Copyright 2014 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 + * + * http://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.data.rest.webmvc.json; + +import java.util.Iterator; +import java.util.List; + +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.util.Assert; + +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationConfig; +import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; + +/** + * Value object to abstract Jackson based bean metadata of a given type. + * + * @author Oliver Gierke + */ +public class JacksonMetadata implements Iterable { + + private final List definitions; + + /** + * Creates a new {@link JacksonMetadata} instance for the given {@link ObjectMapper} and type. + * + * @param mapper must not be {@literal null}. + * @param type must not be {@literal null}. + */ + public JacksonMetadata(ObjectMapper mapper, Class type) { + + Assert.notNull(mapper, "ObjectMapper must not be null!"); + Assert.notNull(type, "Type must not be null!"); + + SerializationConfig serializationConfig = mapper.getSerializationConfig(); + JavaType javaType = serializationConfig.constructType(type); + BeanDescription description = serializationConfig.introspect(javaType); + + this.definitions = description.findProperties(); + } + + /** + * Returns the {@link BeanPropertyDefinition} for the given {@link PersistentProperty}. + * + * @param property must not be {@literal null}. + * @return can be {@literal null} in case there's no Jackson property to be exposed for the given + * {@link PersistentProperty}. + */ + public BeanPropertyDefinition getDefinitionFor(PersistentProperty property) { + + Assert.notNull(property, "PersistentProperty must not be null!"); + + for (BeanPropertyDefinition definition : definitions) { + if (definition.getInternalName().equals(property.getName())) { + return definition; + } + } + + return null; + } + + /* + * (non-Javadoc) + * @see java.lang.Iterable#iterator() + */ + @Override + public Iterator iterator() { + return definitions.iterator(); + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverter.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverter.java index 8df9795b4..67467f084 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverter.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverter.java @@ -20,6 +20,7 @@ import static org.springframework.util.StringUtils.*; import java.util.HashSet; import java.util.Set; +import org.springframework.context.NoSuchMessageException; import org.springframework.context.support.MessageSourceAccessor; import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.converter.ConditionalGenericConverter; @@ -114,8 +115,8 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric final PersistentEntity persistentEntity = repositories.getPersistentEntity((Class) source); final ResourceMetadata metadata = mappings.getMappingFor(persistentEntity.getType()); - final JsonSchema jsonSchema = new JsonSchema(persistentEntity.getName(), accessor.getMessage(metadata - .getItemResourceDescription())); + final JsonSchema jsonSchema = new JsonSchema(persistentEntity.getName(), + resolveMessage(metadata.getItemResourceDescription())); persistentEntity.doWithProperties(new SimplePropertyHandler() { @@ -131,7 +132,7 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric ResourceMapping propertyMapping = metadata.getMappingFor(persistentProperty); ResourceDescription description = propertyMapping.getDescription(); - String message = accessor.getMessage(description); + String message = resolveMessage(description); Property property = persistentProperty.isCollectionLike() ? // new ArrayProperty("array", message, false) @@ -151,4 +152,13 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric return jsonSchema; } + + private String resolveMessage(ResourceDescription description) { + + try { + return accessor.getMessage(description); + } catch (NoSuchMessageException o_O) { + return description.getMessage(); + } + } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryEntityLinks.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryEntityLinks.java index ae4f9fb4d..6fbe62c7c 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryEntityLinks.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryEntityLinks.java @@ -26,6 +26,7 @@ import org.springframework.data.rest.core.config.ProjectionDefinitionConfigurati import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.data.rest.core.mapping.ResourceMetadata; +import org.springframework.data.rest.webmvc.BaseUri; import org.springframework.data.rest.webmvc.spi.BackendIdConverter; import org.springframework.data.rest.webmvc.spi.BackendIdConverter.DefaultIdConverter; import org.springframework.data.web.HateoasPageableHandlerMethodArgumentResolver; @@ -99,7 +100,7 @@ public class RepositoryEntityLinks extends AbstractEntityLinks { public LinkBuilder linkFor(Class type) { ResourceMetadata metadata = mappings.getMappingFor(type); - return new RepositoryLinkBuilder(metadata, config.getBaseUri()); + return new RepositoryLinkBuilder(metadata, new BaseUri(config.getBaseUri())); } /* diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryLinkBuilder.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryLinkBuilder.java index 7b2459225..82f71ac26 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryLinkBuilder.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/RepositoryLinkBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2013 the original author or authors. + * Copyright 2013-2014 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. @@ -19,11 +19,11 @@ import java.net.URI; import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.rest.core.mapping.ResourceMetadata; +import org.springframework.data.rest.webmvc.BaseUri; import org.springframework.hateoas.Link; import org.springframework.hateoas.LinkBuilder; import org.springframework.hateoas.core.LinkBuilderSupport; import org.springframework.util.Assert; -import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder; /** @@ -41,8 +41,8 @@ public class RepositoryLinkBuilder extends LinkBuilderSupport variables = new org.springframework.web.util.UriTemplate(mapping).match(lookupPath); + String value = variables.get(variable); - Map variables = new org.springframework.web.util.UriTemplate(mapping).match(lookupPath); - String value = variables.get(variable); - - if (value != null) { - return value; - } + if (value != null) { + return value; } return null; diff --git a/spring-data-rest-webmvc/src/main/resources/rest-default-messages.properties b/spring-data-rest-webmvc/src/main/resources/rest-default-messages.properties new file mode 100644 index 000000000..6bae86bf2 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/resources/rest-default-messages.properties @@ -0,0 +1,4 @@ +rest.description.pagination.page.description=The page to return. +rest.description.pagination.size.description=The size of the page to return. +rest.description.pagination.sort.description=The sorting criteria to use to calculate the content of the page. +rest.description.projection=The projection that shall be applied when rendering the response. Acceptable values available in nested descriptors. diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/AbstractWebIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/AbstractWebIntegrationTests.java index e895e8cf0..7bdef8ddd 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/AbstractWebIntegrationTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/AbstractWebIntegrationTests.java @@ -460,6 +460,24 @@ public abstract class AbstractWebIntegrationTests { } } + /** + * @see DATAREST-230 + */ + @Test + public void exposesDescriptionAsAlpsDocuments() throws Exception { + + MediaType ALPS_MEDIA_TYPE = MediaType.valueOf("application/alps+json"); + + MockHttpServletResponse response = request("/"); + Link profileLink = assertHasLinkWithRel("profile", response); + + mvc.perform(// + get(profileLink.expand().getHref()).// + accept(ALPS_MEDIA_TYPE)).// + andExpect(status().isOk()).// + andExpect(content().contentType(ALPS_MEDIA_TYPE)); + } + protected abstract Iterable expectedRootLinkRels(); protected Map getPayloadToPost() throws Exception { diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java index e83dcad1c..a0cba6d11 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java @@ -25,9 +25,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.rest.core.mapping.ResourceMetadata; import org.springframework.data.rest.webmvc.ResourceTester.HasSelfLink; import org.springframework.data.rest.webmvc.jpa.Address; +import org.springframework.data.rest.webmvc.jpa.Author; import org.springframework.data.rest.webmvc.jpa.CreditCard; import org.springframework.data.rest.webmvc.jpa.JpaRepositoryConfig; -import org.springframework.data.rest.webmvc.jpa.Order; import org.springframework.data.rest.webmvc.jpa.Person; import org.springframework.data.rest.webmvc.jpa.TestDataPopulator; import org.springframework.hateoas.PagedResources; @@ -73,14 +73,12 @@ public class RepositorySearchControllerIntegrationTests extends AbstractControll @Test(expected = ResourceNotFoundException.class) public void returns404ForUnexportedRepository() { - controller.listSearches(getResourceInformation(CreditCard.class)); } @Test(expected = ResourceNotFoundException.class) public void returns404ForRepositoryWithoutSearches() { - - controller.listSearches(getResourceInformation(Order.class)); + controller.listSearches(getResourceInformation(Author.class)); } @Test @@ -105,7 +103,7 @@ public class RepositorySearchControllerIntegrationTests extends AbstractControll */ @Test(expected = ResourceNotFoundException.class) public void doesNotExposeHeadForSearchResourceIfResourceDoesnHaveSearches() { - controller.headForSearches(getResourceInformation(Order.class)); + controller.headForSearches(getResourceInformation(Author.class)); } /** diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/alps/AlpsControllerIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/alps/AlpsControllerIntegrationTests.java new file mode 100644 index 000000000..5f4966f21 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/alps/AlpsControllerIntegrationTests.java @@ -0,0 +1,119 @@ +/* + * Copyright 2014 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 + * + * http://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.data.rest.webmvc.alps; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.rest.webmvc.AbstractControllerIntegrationTests; +import org.springframework.data.rest.webmvc.jpa.JpaRepositoryConfig; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.LinkDiscoverer; +import org.springframework.hateoas.LinkDiscoverers; +import org.springframework.hateoas.core.JsonPathLinkDiscoverer; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * Integration tests for {@link AlpsController}. + * + * @author Oliver Gierke + */ +@WebAppConfiguration +@ContextConfiguration(classes = { JpaRepositoryConfig.class, AlpsControllerIntegrationTests.Config.class }) +public class AlpsControllerIntegrationTests extends AbstractControllerIntegrationTests { + + @Autowired WebApplicationContext context; + @Autowired LinkDiscoverers discoverers; + + @Configuration + static class Config { + + @Bean + public LinkDiscoverer alpsLinkDiscoverer() { + return new JsonPathLinkDiscoverer("$.descriptors[?(@.name == '%s')].href", + MediaType.valueOf("application/alps+json")); + } + } + + protected MockMvc mvc; + + @Before + public void setUp() { + mvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + /** + * @see DATAREST-230 + */ + @Test + public void exposesProfileLink() throws Exception { + + mvc.perform(get("/")).// + andExpect(status().is2xxSuccessful()).// + andExpect(jsonPath("$._links.profile.href", endsWith(AlpsController.ALPS_ROOT_MAPPING))); + } + + /** + * @see DATAREST-230 + */ + @Test + public void alpsResourceExposesResourcePerCollectionResource() throws Exception { + + Link profileLink = discoverUnique("/", "profile"); + + assertThat(discoverUnique(profileLink.getHref(), "orders"), is(notNullValue())); + assertThat(discoverUnique(profileLink.getHref(), "people"), is(notNullValue())); + } + + /** + * @see DATAREST-230 + */ + @Test + public void exposesAlpsCollectionResources() throws Exception { + + Link profileLink = discoverUnique("/", "profile"); + Link peopleLink = discoverUnique(profileLink.getHref(), "people"); + + mvc.perform(get(peopleLink.getHref())).// + andDo(print()).// + andExpect(jsonPath("$.version").value("1.0")).// + andExpect(jsonPath("$.descriptors[*].name", hasItems("people", "person"))); + } + + private Link discoverUnique(String href, String rel) throws Exception { + + MockHttpServletResponse response = mvc.perform(get(href)).// + andExpect(status().is2xxSuccessful()).// + andReturn().getResponse(); + + LinkDiscoverer discoverer = discoverers.getLinkDiscovererFor(MediaType.valueOf(response.getContentType())); + return discoverer.findLinkWithRel(rel, response.getContentAsString()); + } +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/BookRepository.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/BookRepository.java index 17ca86201..716ea0ff0 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/BookRepository.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/BookRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2013 the original author or authors. + * Copyright 2013-2014 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. diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Order.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Order.java index 00189122d..97476b3cf 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Order.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Order.java @@ -41,6 +41,7 @@ public class Order { private Person creator; @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)// private List lineItems = new ArrayList(); + private Type type = Type.TAKE_AWAY; public Order(Person creator) { this.creator = creator; @@ -72,4 +73,11 @@ public class Order { public BigDecimal getPrice() { return new BigDecimal(2.50); } + + /** + * @return the type + */ + public Type getType() { + return type; + } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/OrderRepository.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/OrderRepository.java index e1fd96085..f610e8980 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/OrderRepository.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/OrderRepository.java @@ -15,13 +15,19 @@ */ package org.springframework.data.rest.webmvc.jpa; +import java.util.List; + import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.data.rest.core.annotation.Description; import org.springframework.data.rest.core.annotation.RepositoryRestResource; /** * @author Oliver Gierke */ -@RepositoryRestResource +@RepositoryRestResource(collectionResourceDescription = @Description("Collection resource description"), + itemResourceDescription = @Description("Item resource description.")) public interface OrderRepository extends CrudRepository { + List findByType(@Param("type") Type type); } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/OrderSummary.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/OrderSummary.java index a3017f8ac..f650250c8 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/OrderSummary.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/OrderSummary.java @@ -17,13 +17,16 @@ package org.springframework.data.rest.webmvc.jpa; import java.math.BigDecimal; +import org.springframework.data.rest.core.annotation.Description; import org.springframework.data.rest.core.config.Projection; /** * @author Oliver Gierke */ @Projection(name = "summary", types = Order.class) +@Description("A summary of an order.") public interface OrderSummary { + @Description("Price!!") BigDecimal getPrice(); } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Type.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Type.java new file mode 100644 index 000000000..f95a48092 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Type.java @@ -0,0 +1,24 @@ +/* + * Copyright 2014 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 + * + * http://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.data.rest.webmvc.jpa; + +/** + * @author Oliver Gierke + */ +public enum Type { + + IN_STORE, TAKE_AWAY; +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/RepositoryLinkBuildUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/RepositoryLinkBuildUnitTests.java index b7ae2cdb5..80ff8480a 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/RepositoryLinkBuildUnitTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/RepositoryLinkBuildUnitTests.java @@ -18,13 +18,12 @@ package org.springframework.data.rest.webmvc.support; import static org.hamcrest.CoreMatchers.*; import static org.junit.Assert.*; -import java.net.URI; - import org.junit.Test; import org.springframework.data.mongodb.core.mapping.MongoMappingContext; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.rest.core.mapping.MappingResourceMetadata; import org.springframework.data.rest.core.mapping.ResourceMetadata; +import org.springframework.data.rest.webmvc.BaseUri; import org.springframework.data.rest.webmvc.WebTestUtils; import org.springframework.data.rest.webmvc.mongodb.Profile; import org.springframework.hateoas.Link; @@ -62,7 +61,7 @@ public class RepositoryLinkBuildUnitTests { MongoPersistentEntity entity = context.getPersistentEntity(Profile.class); ResourceMetadata metadata = new MappingResourceMetadata(entity); - RepositoryLinkBuilder builder = new RepositoryLinkBuilder(metadata, URI.create(baseUri)); + RepositoryLinkBuilder builder = new RepositoryLinkBuilder(metadata, new BaseUri(baseUri)); Link link = builder.withSelfRel(); assertThat(link.getHref(), is(expectedUri));