DATAREST-230 - Support for serving Alps documents.

This commit adds the support to expose additional resources that serve ALPS [0] resources to document the available state transitions and representations. The exposure is enabled by default and can be customized using the RepositoryRestConfiguration.metadataConfiguration() object.

Currently the set of descriptors exposed includes:

- A descriptor for the representation of the domain type. Linkable associations are represented as safe descriptors, ones that are not linked are semantic descriptors.
- Descriptors for each supported HTTP method for both the collection and item resources to indicate the ability to update, create, delete etc.
- Safe descriptors (e.g. to access the collection or item resource) get potentially available customizations (pagination, projections) attached through nested descriptors.

Documentation

An ALPS descriptor contains a doc attribute to carry semantic information for the end user or a potential client to display. The information can be described in two ways: the first one is the @Description annotation that captures the plain text information one wants to get listed. It is supported in @RepositoryRestResource, on query methods, projection interfaces and accessors etc.

The preferred approach however is to use a resource bundle rest-messages.properties. For each descriptor we will resolve a key starting with rest.description followed by a dot path into the resource. By default, doc attributes are only rendered if the resource bundle contains an entry for the relevant key. You can enforce displaying unresolved keys by configuring MetadataConfiguration.omitUnresolvableDescriptionKeys(…).

For representation descriptors and and the parameter list of query method descriptors we will display enum values by default as comma-separated list. The list is also available as message resolution argument, so that you can refer to the list in your description message via the {0} placeholder.

TODOs:

- Improve descriptors for associations (indicate ability to update etc.)

[0] ALPS - http://alps.io
This commit is contained in:
Oliver Gierke
2014-03-25 10:44:28 +01:00
parent 0f0d23c6b1
commit 020de45c1b
39 changed files with 1643 additions and 150 deletions

View File

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

View File

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

View File

@@ -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<String, Class<?>> getProjectionsFor(Class<?> sourceType) {
Assert.notNull(sourceType, "Source type must not be null!");
Map<String, Class<?>> result = new HashMap<String, Class<?>>();
for (Entry<ProjectionDefinitionKey, Class<?>> 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.
*

View File

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

View File

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

View File

@@ -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<String> getParameterNames();
ParametersMetadata getParametersMetadata();
}

View File

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

View File

@@ -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<ParameterMetadata> {
private final List<ParameterMetadata> parameterMetadata;
/**
* Creates a new {@link ParametersMetadata} instance for the given {@link ParameterMetadata} instances.
*
* @param parameterMetadata must not be {@literal null}.
*/
ParametersMetadata(List<ParameterMetadata> parameterMetadata) {
Assert.notNull(parameterMetadata, "Parameter metadata must not be null!");
this.parameterMetadata = parameterMetadata;
}
/**
* Returns all parameter names.
*
* @return
*/
public List<String> getParameterNames() {
List<String> names = new ArrayList<String>(parameterMetadata.size());
for (ParameterMetadata metadata : parameterMetadata) {
names.add(metadata.getName());
}
return names;
}
/*
* (non-Javadoc)
* @see java.lang.Iterable#iterator()
*/
@Override
public Iterator<ParameterMetadata> iterator() {
return parameterMetadata.iterator();
}
}

View File

@@ -47,7 +47,7 @@ class RepositoryMethodResourceMapping implements MethodResourceMapping {
private final Method method;
private final boolean paging;
private final List<String> parameterNames;
private final List<ParameterMetadata> 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<String> discoverParameterNames(Method method) {
private static final List<ParameterMetadata> discoverParameterMetadata(Method method, String baseRel) {
List<String> result = new ArrayList<String>();
List<ParameterMetadata> result = new ArrayList<ParameterMetadata>();
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<String> getParameterNames() {
return parameterNames;
public ParametersMetadata getParametersMetadata() {
return new ParametersMetadata(parameterMetadata);
}
/*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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