diff --git a/pom.xml b/pom.xml index e013db584..2c718edcb 100644 --- a/pom.xml +++ b/pom.xml @@ -58,7 +58,7 @@ UTF-8 - 3.1.4.RELEASE + 3.2.3.RELEASE 1.0.9 1.9.10 2.1.1 diff --git a/src/main/java/org/springframework/hateoas/HtmlResourceMessageConverter.java b/src/main/java/org/springframework/hateoas/HtmlResourceMessageConverter.java new file mode 100644 index 000000000..13641f335 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/HtmlResourceMessageConverter.java @@ -0,0 +1,150 @@ +package org.springframework.hateoas; + +import java.io.IOException; +import java.util.Map.Entry; + +import org.springframework.hateoas.action.ActionDescriptor; +import org.springframework.hateoas.action.Input; +import org.springframework.hateoas.action.Type; +import org.springframework.hateoas.mvc.MethodParameterValue; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.converter.AbstractHttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.util.FileCopyUtils; + +/** + * Message converter which converts one ActionDescriptor or an array of ActionDescriptor items to an HTML page + * containing one form per ActionDescriptor. + * + * Add the following to your spring configuration to enable this converter: + * + *
+ *   <bean
+ *     class="org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerAdapter">
+ *     <property name="messageConverters">
+ *       <list>
+ *         <ref bean="jsonConverter" />
+ *         <ref bean="htmlFormMessageConverter" />
+ *       </list>
+ *     </property>
+ *   </bean>
+ *
+ *   <bean id="htmlFormMessageConverter" class="org.springframework.hateoas.HtmlResourceMessageConverter">
+ *     <property name="supportedMediaTypes" value="text/html" />
+ *   </bean>
+ * 
+ * + * @author Dietrich Schulten + * + */ +public class HtmlResourceMessageConverter extends AbstractHttpMessageConverter { + + /** expects title */ + public static final String HTML_START = "" + // + // "" + // formatter + "" + // + "" + // + " " + // + " %s" + // + " " + // + " "; + + /** expects action url, form name, form method, form h1 */ + public static final String FORM_START = "" + // + "
" + // + "

%s

"; // + + /** expects field label, input field type and field name */ + public static final String FORM_INPUT = "" + // + " "; + + /** closes the form */ + public static final String FORM_END = "" + // + " " + // + "
"; + public static final String HTML_END = "" + // + " " + // + ""; + + @Override + protected boolean supports(Class clazz) { + final boolean ret; + if (ActionDescriptor.class == clazz || ActionDescriptor[].class == clazz) { + ret = true; + } else { + ret = false; + } + return ret; + } + + @Override + protected Object readInternal(Class clazz, HttpInputMessage inputMessage) throws IOException, + HttpMessageNotReadableException { + return new Object(); + } + + @Override + protected void writeInternal(Object t, HttpOutputMessage outputMessage) throws IOException, + HttpMessageNotWritableException { + + StringBuilder sb = new StringBuilder(); + sb.append(String.format(HTML_START, "Input Data")); + + if (t instanceof ActionDescriptor[]) { + ActionDescriptor[] descriptors = (ActionDescriptor[]) t; + for (ActionDescriptor actionDescriptor : descriptors) { + appendForm(sb, actionDescriptor); + } + } else { + ActionDescriptor actionDescriptor = (ActionDescriptor) t; + appendForm(sb, actionDescriptor); + } + sb.append(HTML_END); + FileCopyUtils.copy(sb.toString().getBytes("UTF-8"), outputMessage.getBody()); + + } + + private void appendForm(StringBuilder sb, ActionDescriptor actionDescriptor) { + String action = actionDescriptor.getRelativeActionLink(); + String formName = actionDescriptor.getResourceName(); + + String formH1 = "Form " + formName; + sb.append(String.format(FORM_START, action, formName, actionDescriptor.getHttpMethod().toString(), formH1)); + + // build the form + for (Entry requestParam : actionDescriptor.getRequestParams().entrySet()) { + + String requestParamName = requestParam.getKey(); + MethodParameterValue methodParameterValue = requestParam.getValue(); + String inputFieldType = getInputFieldType(methodParameterValue); + String fieldLabel = requestParamName + ": "; + // TODO support list and matrix parameters? + // TODO support RequestBody mapped by object marshaler? Look at bean properties in that case instead of + // RequestParam arguments. + // TODO support valid value ranges, possible values, value constraints? + Object invocationValue = methodParameterValue.getCallValue(); + sb.append(String.format(FORM_INPUT, fieldLabel, inputFieldType, requestParamName, invocationValue == null ? "" + : invocationValue.toString())); + } + sb.append(FORM_END); + } + + private String getInputFieldType(MethodParameterValue methodParameterValue) { + final String ret; + Input inputAnnotation = methodParameterValue.getParameterAnnotation(Input.class); + if (inputAnnotation != null) { + ret = inputAnnotation.value().toString(); + } else { + Class parameterType = methodParameterValue.getParameterType(); + if (Number.class.isAssignableFrom(parameterType)) { + ret = Type.NUMBER.toString(); + } else { + ret = Type.TEXT.toString(); + } + } + return ret; + } + +} diff --git a/src/main/java/org/springframework/hateoas/action/ActionDescriptor.java b/src/main/java/org/springframework/hateoas/action/ActionDescriptor.java new file mode 100644 index 000000000..0fe245a97 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/action/ActionDescriptor.java @@ -0,0 +1,62 @@ +package org.springframework.hateoas.action; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.hateoas.mvc.MethodParameterValue; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.util.UriComponents; + +/** + * Describes an HTTP action, e.g. for a form that calls a Controller method which handles the request built by the form. + * + * @author Dietrich Schulten + * + */ +public class ActionDescriptor { + + private UriComponents actionLink; + private Map requestParams = new HashMap(); + private RequestMethod httpMethod; + private String resourceName; + + /** + * Creates an action descriptor. E.g. can be used to create HTML forms. + * + * @param resourceName can be used by the action representation, e.g. to identify the action using a form name. + * @param actionLink to which the action is submitted + * @param requestMethod used during submit + */ + public ActionDescriptor(String resourceName, UriComponents actionLink, RequestMethod requestMethod) { + this.actionLink = actionLink; + this.httpMethod = requestMethod; + this.resourceName = resourceName; + } + + public String getResourceName() { + return resourceName; + } + + public RequestMethod getHttpMethod() { + return httpMethod; + } + + public UriComponents getActionLink() { + return actionLink; + } + + public String getRelativeActionLink() { + return actionLink.getPath(); + } + + + public Map getRequestParams() { + return requestParams; + } + + + public void addRequestParam(String key, MethodParameterValue methodParameterValue) { + requestParams.put(key, methodParameterValue); + } + +} diff --git a/src/main/java/org/springframework/hateoas/action/Input.java b/src/main/java/org/springframework/hateoas/action/Input.java new file mode 100644 index 000000000..f54a7561a --- /dev/null +++ b/src/main/java/org/springframework/hateoas/action/Input.java @@ -0,0 +1,27 @@ +package org.springframework.hateoas.action; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Allows to mark a method parameter as Hidden, e.g. when used as a POST parameter for a form which is not supposed to + * be changed by the client. + * + * @author Dietrich Schulten + * + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Input { + + Type value(); + + int max() default -1; + + int min() default -1; + + int step() default -1; + +} diff --git a/src/main/java/org/springframework/hateoas/action/Type.java b/src/main/java/org/springframework/hateoas/action/Type.java new file mode 100644 index 000000000..da890c4ba --- /dev/null +++ b/src/main/java/org/springframework/hateoas/action/Type.java @@ -0,0 +1,21 @@ +package org.springframework.hateoas.action; + +public enum Type { + TEXT("text"), HIDDEN("hidden"), PASSWORD("password"), COLOR("color"), DATE("date"), DATETIME("datetime"), DATETIME_LOCAL( + "datetime-local"), EMAIL("email"), MONTH("month"), NUMBER("number"), RANGE("range"), SEARCH("search"), TEL("tel"), TIME( + "time"), URL("url"), WEEK("week"); + + private String value; + + Type(String value) { + this.value = value; + } + + /** + * Returns the correct html input type string value. + */ + public String toString() { + return value; + } + +} diff --git a/src/main/java/org/springframework/hateoas/action/package-info.java b/src/main/java/org/springframework/hateoas/action/package-info.java new file mode 100644 index 000000000..c5e683cd7 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/action/package-info.java @@ -0,0 +1,5 @@ +/** + * Classes supporting the creation of forms-like resources, e.g. {@link ControllerActionBuilder} + */ +package org.springframework.hateoas.action; + diff --git a/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java b/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java index 9ea1128dd..3d0f270b8 100644 --- a/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java +++ b/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java @@ -19,6 +19,7 @@ import java.util.List; import java.util.Map; +import java.util.Arrays; import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactory; @@ -201,7 +202,7 @@ public Object postProcessAfterInitialization(Object bean, String beanName) throw } if (bean instanceof AnnotationMethodHandlerAdapter) { - registerModule(((AnnotationMethodHandlerAdapter) bean).getMessageConverters()); + registerModule(Arrays.asList(((AnnotationMethodHandlerAdapter) bean).getMessageConverters())); } if (bean instanceof ObjectMapper) { @@ -265,14 +266,14 @@ public Object postProcessBeforeInitialization(Object bean, String beanName) thro @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { - if (bean instanceof AnnotationMethodHandlerAdapter) { - registerModule(((AnnotationMethodHandlerAdapter) bean).getMessageConverters()); - } - if (bean instanceof RequestMappingHandlerAdapter) { registerModule(((RequestMappingHandlerAdapter) bean).getMessageConverters()); } + if (bean instanceof AnnotationMethodHandlerAdapter) { + registerModule(Arrays.asList(((AnnotationMethodHandlerAdapter) bean).getMessageConverters())); + } + if (bean instanceof org.codehaus.jackson.map.ObjectMapper) { registerModule(bean); } diff --git a/src/main/java/org/springframework/hateoas/mvc/AnnotatedParametersParameterAccessor.java b/src/main/java/org/springframework/hateoas/mvc/AnnotatedParametersParameterAccessor.java index 764a78104..343553c66 100644 --- a/src/main/java/org/springframework/hateoas/mvc/AnnotatedParametersParameterAccessor.java +++ b/src/main/java/org/springframework/hateoas/mvc/AnnotatedParametersParameterAccessor.java @@ -17,7 +17,9 @@ import java.lang.annotation.Annotation; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.aopalliance.intercept.MethodInvocation; import org.springframework.core.MethodParameter; @@ -84,7 +86,7 @@ static class BoundMethodParameter { private final MethodParameter parameter; private final Object value; private final AnnotationAttribute attribute; - private final TypeDescriptor parameterTypeDecsriptor; + private final TypeDescriptor parameterTypeDescriptor; /** * Creates a new {@link BoundMethodParameter} @@ -100,7 +102,7 @@ public BoundMethodParameter(MethodParameter parameter, Object value, AnnotationA this.parameter = parameter; this.value = value; this.attribute = attribute; - this.parameterTypeDecsriptor = TypeDescriptor.nested(parameter, 0); + this.parameterTypeDescriptor = TypeDescriptor.nested(parameter, 0); } /** @@ -140,7 +142,21 @@ public String asString() { return null; } - return (String) CONVERSION_SERVICE.convert(value, parameterTypeDecsriptor, STRING_DESCRIPTOR); + return (String) CONVERSION_SERVICE.convert(value, parameterTypeDescriptor, STRING_DESCRIPTOR); } } + + public Map getBoundMethodParameterValues(MethodInvocation invocation) { + + List boundParameters = getBoundParameters(invocation); + Map result = new HashMap(); + for (BoundMethodParameter boundMethodParameter : boundParameters) { + String key = boundMethodParameter.getVariableName(); + MethodParameter parameter = boundMethodParameter.parameter; + Object value = boundMethodParameter.getValue(); + String formatted = boundMethodParameter.asString(); + result.put(key , new MethodParameterValue(parameter , value, formatted)); + } + return result; + } } diff --git a/src/main/java/org/springframework/hateoas/mvc/ControllerActionBuilder.java b/src/main/java/org/springframework/hateoas/mvc/ControllerActionBuilder.java new file mode 100644 index 000000000..f7f8ebb63 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mvc/ControllerActionBuilder.java @@ -0,0 +1,117 @@ +package org.springframework.hateoas.mvc; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.Map.Entry; + +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.hateoas.HtmlResourceMessageConverter; +import org.springframework.hateoas.action.ActionDescriptor; +import org.springframework.hateoas.core.AnnotationAttribute; +import org.springframework.hateoas.core.DummyInvocationUtils.LastInvocationAware; +import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +public class ControllerActionBuilder { + + private static final AnnotatedParametersParameterAccessor REQUEST_PARAM_ACCESSOR = new AnnotatedParametersParameterAccessor( + new AnnotationAttribute(RequestParam.class)); + + /** + * Creates an ActionDescriptor which tells a hypermedia client how to pass data to a resource, e.g. how to construct a + * POST or PUT body or how to fill in GET parameters. The action descriptor knows the names of the expected + * parameters, their types, possible values and default values. + *

+ * The action descriptor can be used by message converters to create a response in a hypermedia-enabled media type + * (see below for examples). Another possibility is to generate a json schema or a html documentation page from the + * action descriptor and make it available at a custom rel's URI. + *

+ * For instance, the {@link HtmlResourceMessageConverter} can create xhtml forms, which can be used by hypermedia-enabled + * clients. + *

+ * The following example method searchPersonForm creates a search form which has the method showPerson as action + * target. It is returned by the application if the client requests the /person resource without parameters. + * + *

+	 * @RequestMapping(value = "/person")
+	 * public HttpEntity<FormDescriptor> searchPersonForm() {
+	 * 	ActionDescriptor rd = ControllerActionBuilder.actionFor("searchPerson", methodOn(PersonController.class)
+	 * 			.showPerson(null));
+	 * 	return new HttpEntity<ActionDescriptor>(rd);
+	 * }
+	 * 
+	 * @RequestMapping(value = "/person", method = RequestMethod.GET, params = { "personId" })
+	 * 	public HttpEntity<? extends Object> showPerson(@RequestParam(value = "personId") Long personId) {
+	 * 		...
+	 * }
+	 * 
+	 * 
+ * + * If you want to predefine a default value for the searchPerson request parameter, pass it into the method + * invocation. + * + *
+	 * methodOn(PersonController.class).showPerson(1234L);
+	 * 
+ * + * This way, the form will have a predefined value of 1234 in the personId form field. + * + * @param invocationValue method reference which will handle the request, use + * {@link ControllerLinkBuilder#methodOn(Class, Object...)} to create a suitable method reference + * @param actionName name of the action, e.g. to be used as a form name + * + * @return resource descriptor + * @throws IllegalStateException if the method has no request mapping + * @see HtmlResourceMessageConverter + * @see Hypermedia APIs with xhtml + * @see Siren Hypermedia Format + * @see Why does HAL have no forms + */ + public static ActionDescriptor actionFor(Object invocationValue, String actionName) { + + Assert.isInstanceOf(LastInvocationAware.class, invocationValue); + LastInvocationAware invocations = (LastInvocationAware) invocationValue; + + MethodInvocation invocation = invocations.getLastInvocation(); + Method invokedMethod = invocation.getMethod(); + + ControllerLinkBuilder linkBuilder = ControllerLinkBuilder.linkTo(invocationValue); + UriComponents uri = linkBuilder.toUriComponentsBuilder().build(); + UriComponentsBuilder actionUriBuilder = UriComponentsBuilder.newInstance(); + UriComponents actionUri = actionUriBuilder.scheme(uri.getScheme()).userInfo(uri.getUserInfo()).host(uri.getHost()) + .port(uri.getPort()).path(uri.getPath()).build(); + RequestMethod requestMethod = getRequestMethod(invokedMethod); + ActionDescriptor actionDescriptor = new ActionDescriptor(actionName, actionUri, requestMethod); + + // the action descriptor needs to know the param type, value and name + Map requestParamMap = REQUEST_PARAM_ACCESSOR + .getBoundMethodParameterValues(invocation); + for (Entry entry : requestParamMap.entrySet()) { + actionDescriptor.addRequestParam(entry.getKey(), entry.getValue()); + } + + return actionDescriptor; + } + + private static RequestMethod getRequestMethod(Method method) { + RequestMapping methodRequestMapping = AnnotationUtils.findAnnotation(method, RequestMapping.class); + RequestMethod requestMethod; + if (methodRequestMapping != null) { + RequestMethod[] methods = methodRequestMapping.method(); + if (methods.length == 0) { + requestMethod = RequestMethod.GET; + } else { + requestMethod = methods[0]; + } + } else { + requestMethod = RequestMethod.GET; // default + } + return requestMethod; + } + +} diff --git a/src/main/java/org/springframework/hateoas/mvc/MethodParameterValue.java b/src/main/java/org/springframework/hateoas/mvc/MethodParameterValue.java new file mode 100644 index 000000000..412a55117 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mvc/MethodParameterValue.java @@ -0,0 +1,40 @@ +package org.springframework.hateoas.mvc; + +import org.springframework.core.MethodParameter; + +/** + * Holds a method parameter value together with {@link MethodParameter} information. + * + * @author Dietrich Schulten + * + */ +public class MethodParameterValue extends MethodParameter { + + private Object value; + private String formattedValue; + + public MethodParameterValue(MethodParameter original, Object value, String formattedValue) { + super(original); + this.value = value; + this.formattedValue = formattedValue; + } + + /** + * The value of the parameter at invocation time. + * + * @return value, may be null + */ + public Object getCallValue() { + return value; + } + + /** + * The value of the parameter at invocation time, formatted according to conversion configuration. + * + * @return value, may be null + */ + public String getCallValueFormatted() { + return formattedValue; + } + +} diff --git a/src/main/java/org/springframework/hateoas/mvc/ResourceAssemblerSupport.java b/src/main/java/org/springframework/hateoas/mvc/ResourceAssemblerSupport.java index 13a3648ce..d8fd68110 100755 --- a/src/main/java/org/springframework/hateoas/mvc/ResourceAssemblerSupport.java +++ b/src/main/java/org/springframework/hateoas/mvc/ResourceAssemblerSupport.java @@ -81,6 +81,15 @@ protected D createResourceWithId(Object id, T entity) { return createResourceWithId(id, entity, new Object[0]); } + /** + * Creates a new resource with a self link to the given id, using the given parameters to replace path variables in + * the request mapping of the given controller class. + * + * @param id must not be {@literal null}. + * @param entity must not be {@literal null}. + * @param parameters for path variables, using their id if {@link Identifiable} + * @return + */ protected D createResourceWithId(Object id, T entity, Object... parameters) { Assert.notNull(entity); diff --git a/src/test/java/org/springframework/hateoas/AbstractMarshallingIntegrationTests.java b/src/test/java/org/springframework/hateoas/AbstractMarshallingIntegrationTests.java index da661b0e8..4ce7cb376 100644 --- a/src/test/java/org/springframework/hateoas/AbstractMarshallingIntegrationTests.java +++ b/src/test/java/org/springframework/hateoas/AbstractMarshallingIntegrationTests.java @@ -22,7 +22,7 @@ import org.junit.Before; /** - * Base class to eas integration tests for {@link ObjectMapper} marshalling. + * Base class to ease integration tests for {@link ObjectMapper} marshalling. * * @author Oliver Gierke */ diff --git a/src/test/java/org/springframework/hateoas/HtmlResourceMessageConverterTest.java b/src/test/java/org/springframework/hateoas/HtmlResourceMessageConverterTest.java new file mode 100644 index 000000000..1349f67c1 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/HtmlResourceMessageConverterTest.java @@ -0,0 +1,96 @@ +package org.springframework.hateoas; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.xpath; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.hateoas.sample.SamplePersonController; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.web.context.WebApplicationContext; + +/** + * Tests html form message creation for /customer resource on {@link SamplePersonController}. + * + * @author Dietrich Schulten + * + */ +@RunWith(SpringJUnit4ClassRunner.class) +@WebAppConfiguration +@ContextConfiguration +public class HtmlResourceMessageConverterTest { + @Autowired + private WebApplicationContext wac; + + private MockMvc mockMvc; + private static Map namespaces = new HashMap(); + + static { + namespaces.put("h", "http://www.w3.org/1999/xhtml"); + } + + @Before + public void setup() { + this.mockMvc = webAppContextSetup(this.wac).build(); + + } + + @Test + public void testCreatesHtmlForm() throws Exception { + this.mockMvc.perform(get("/people/customer").accept(MediaType.TEXT_HTML)).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.TEXT_HTML)) + .andExpect(xpath("h:html/h:body/h:form/@action", namespaces).string("/people/customer")) + .andExpect(xpath("//h:form/@name", namespaces).string("searchPerson")) + .andExpect(xpath("//h:form/@method", namespaces).string("GET")); + } + + @Test + public void testCreatesInputFieldWithDefaultNumber() throws Exception { + + this.mockMvc.perform(get("/people/customer").accept(MediaType.TEXT_HTML)).andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.TEXT_HTML)) + .andExpect(xpath("//h:input/@name", namespaces).string("personId")) + .andExpect(xpath("//h:input/@type", namespaces).string("number")) + .andExpect(xpath("//h:input/@value", namespaces).string("1234")); + } + + @Test + public void testCreatesInputFieldWithDefaultText() throws Exception { + + this.mockMvc.perform(get("/people/customerByName").accept(MediaType.TEXT_HTML)).andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.TEXT_HTML)) + .andExpect(xpath("//h:input/@name", namespaces).string("name")) + .andExpect(xpath("//h:input/@type", namespaces).string("text")) + .andExpect(xpath("//h:input/@value", namespaces).string("Bombur")); + } + + /** + * Tests if the form contains a personId input field with default value. + * + * @throws Exception + */ + @Test + public void testCreatesHiddenInputField() throws Exception { + + this.mockMvc.perform(get("/people/customer/editor").accept(MediaType.TEXT_HTML)).andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.TEXT_HTML)) + .andExpect(xpath("//h:input[@name='personId']/@name", namespaces).string("personId")) + .andExpect(xpath("//h:input[@name='personId']/@type", namespaces).string("hidden")) + .andExpect(xpath("//h:input[@name='personId']/@value", namespaces).string("1234")) + .andExpect(xpath("//h:input[@name='firstname']/@value", namespaces).string("Bilbo")); + } + +} diff --git a/src/test/java/org/springframework/hateoas/mvc/ControllerActionBuilderTest.java b/src/test/java/org/springframework/hateoas/mvc/ControllerActionBuilderTest.java new file mode 100644 index 000000000..179e0630a --- /dev/null +++ b/src/test/java/org/springframework/hateoas/mvc/ControllerActionBuilderTest.java @@ -0,0 +1,34 @@ +package org.springframework.hateoas.mvc; + +import static org.junit.Assert.assertEquals; +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.methodOn; + +import org.junit.Test; +import org.springframework.hateoas.TestUtils; +import org.springframework.hateoas.action.ActionDescriptor; +import org.springframework.http.HttpEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; + +public class ControllerActionBuilderTest extends TestUtils { + + + @Test + public void createsRelativeLinkToFormWithMethodLevelAndTypeLevelVariables() throws Exception { + ActionDescriptor formDescriptor = ControllerActionBuilder.actionFor(methodOn(PersonControllerForForm.class, "region1").showPerson("mike", null), + "searchPerson"); + assertEquals("/region/region1/person/mike", formDescriptor.getRelativeActionLink()); + } + + @RequestMapping("/region/{regionId}") + static class PersonControllerForForm { + @RequestMapping(value = "/person/{personName}", method = RequestMethod.POST) + public HttpEntity showPerson(@PathVariable("personName") String bar, + @RequestParam(value = "personId") Long personId) { + return null; + } + } + +} diff --git a/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java b/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java index 595881a91..e99ba9de9 100644 --- a/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java +++ b/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java @@ -222,6 +222,16 @@ public Long getId() { } } + static class Product implements Identifiable { + + Long id; + + @Override + public Long getId() { + return id; + } + } + @RequestMapping("/people") interface PersonController { @@ -274,4 +284,5 @@ HttpEntity methodWithMultiValueRequestParams(@PathVariable String id, @Req return null; } } + } diff --git a/src/test/java/org/springframework/hateoas/mvc/ProductsController.java b/src/test/java/org/springframework/hateoas/mvc/ProductsController.java new file mode 100644 index 000000000..1cb809b30 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/mvc/ProductsController.java @@ -0,0 +1,35 @@ +package org.springframework.hateoas.mvc; + +import java.util.List; +import java.util.Map; + +import org.springframework.hateoas.mvc.ControllerLinkBuilderUnitTest.Person; +import org.springframework.hateoas.mvc.ControllerLinkBuilderUnitTest.Product; +import org.springframework.http.HttpEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +public class ProductsController { + + @RequestMapping(value = "/products") + public HttpEntity> products() { + return null; + } + + @RequestMapping(value = "/products/{productId}") + public HttpEntity product(@PathVariable Long productId) { + return null; + } + + @RequestMapping(value = "/products/{productId}/details", params = "attr") + public HttpEntity> productDetails(@PathVariable Long personId, + @RequestParam(value = "attr", required = true) String[] attr) { + return null; + } + + @RequestMapping(value = "/people/{personId}/products") + public HttpEntity> productsOfPerson(@PathVariable Long personId) { + return null; + } +} \ No newline at end of file diff --git a/src/test/java/org/springframework/hateoas/sample/SamplePerson.java b/src/test/java/org/springframework/hateoas/sample/SamplePerson.java new file mode 100644 index 000000000..8f95fe1c4 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/sample/SamplePerson.java @@ -0,0 +1,35 @@ +package org.springframework.hateoas.sample; + +import org.springframework.hateoas.Identifiable; + +public class SamplePerson implements Identifiable { + + private Long id; + private String firstname; + private String lastname; + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + +} diff --git a/src/test/java/org/springframework/hateoas/sample/SamplePersonController.java b/src/test/java/org/springframework/hateoas/sample/SamplePersonController.java new file mode 100644 index 000000000..9970ba02d --- /dev/null +++ b/src/test/java/org/springframework/hateoas/sample/SamplePersonController.java @@ -0,0 +1,80 @@ +package org.springframework.hateoas.sample; + +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.methodOn; + +import org.springframework.hateoas.action.ActionDescriptor; +import org.springframework.hateoas.action.Input; +import org.springframework.hateoas.action.Type; +import org.springframework.hateoas.mvc.ControllerActionBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +@RequestMapping("/people") +public class SamplePersonController { + + private static SamplePerson person = new SamplePerson(); + static { + person.setId(1234L); + person.setFirstname("Bilbo"); + person.setLastname("Baggins"); + } + + @RequestMapping(value = "/customer") + public HttpEntity searchPersonForm() { + long defaultPersonId = 1234L; + ActionDescriptor form = ControllerActionBuilder.actionFor( + methodOn(SamplePersonController.class).showPerson(defaultPersonId), "searchPerson"); + return new HttpEntity(form); + } + + @RequestMapping(value = "/customer", method = RequestMethod.GET, params = { "personId" }) + public HttpEntity showPerson(@RequestParam Long personId) { + + SamplePersonResourceAssembler assembler = new SamplePersonResourceAssembler(); + SamplePersonResource resource = assembler.toResource(person); + + return new HttpEntity(resource); + } + + @RequestMapping(value = "/customerByName") + public HttpEntity searchPersonByNameForm() { + String defaultName = "Bombur"; + ActionDescriptor form = ControllerActionBuilder.actionFor( + methodOn(SamplePersonController.class).showPerson(defaultName), "searchPerson"); + return new HttpEntity(form); + } + + @RequestMapping(value = "/customerByName", method = RequestMethod.GET, params = { "name" }) + public HttpEntity showPerson(@RequestParam String name) { + + SamplePersonResourceAssembler assembler = new SamplePersonResourceAssembler(); + SamplePersonResource resource = assembler.toResource(person); + + return new HttpEntity(resource); + } + + @RequestMapping(value = "/customer/editor") + public HttpEntity editPersonForm() { + ActionDescriptor descriptor = ControllerActionBuilder.actionFor(methodOn(SamplePersonController.class) + .editPerson(person.getId(), person.getFirstname(), person.getLastname()), "changePerson"); + + return new HttpEntity(descriptor); + } + + @RequestMapping(value = "/customer", method = RequestMethod.PUT, params = { "personId", "firstname", "lastname" }) + public HttpEntity editPerson(@RequestParam @Input(Type.HIDDEN) Long personId, + @RequestParam String firstname, @RequestParam String lastname) { + + person.setFirstname(firstname); + person.setLastname(lastname); + SamplePersonResourceAssembler assembler = new SamplePersonResourceAssembler(); + SamplePersonResource resource = assembler.toResource(person); + + return new HttpEntity(resource); + } + +} diff --git a/src/test/java/org/springframework/hateoas/sample/SamplePersonResource.java b/src/test/java/org/springframework/hateoas/sample/SamplePersonResource.java new file mode 100644 index 000000000..a1464c869 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/sample/SamplePersonResource.java @@ -0,0 +1,25 @@ +package org.springframework.hateoas.sample; + +import org.springframework.hateoas.Resource; + +public class SamplePersonResource extends Resource { + + String firstname; + String lastname; + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } +} diff --git a/src/test/java/org/springframework/hateoas/sample/SamplePersonResourceAssembler.java b/src/test/java/org/springframework/hateoas/sample/SamplePersonResourceAssembler.java new file mode 100644 index 000000000..47fb48a3c --- /dev/null +++ b/src/test/java/org/springframework/hateoas/sample/SamplePersonResourceAssembler.java @@ -0,0 +1,18 @@ +package org.springframework.hateoas.sample; + +import org.springframework.hateoas.mvc.ResourceAssemblerSupport; + +public class SamplePersonResourceAssembler extends ResourceAssemblerSupport { + + public SamplePersonResourceAssembler() { + super(SamplePersonController.class, SamplePersonResource.class); + } + + public SamplePersonResource toResource(SamplePerson person) { + SamplePersonResource resource = createResourceWithId(person.getId(), person); + resource.setFirstname(person.getFirstname()); + resource.setLastname(person.getLastname()); + return resource; + } + +} diff --git a/src/test/resources/org/springframework/hateoas/HtmlResourceMessageConverterTest-context.xml b/src/test/resources/org/springframework/hateoas/HtmlResourceMessageConverterTest-context.xml new file mode 100644 index 000000000..16008ecf8 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/HtmlResourceMessageConverterTest-context.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + text/html + application/xhtml+xml + + + + + \ No newline at end of file