From 85ced098c64061295fd791eac5173b656e9bd406 Mon Sep 17 00:00:00 2001 From: Oliver Gierke Date: Tue, 14 Apr 2015 16:35:21 +0200 Subject: [PATCH] DATAREST-293 - Added new module to easily add a HAL browser to Spring Data REST apps. Added a module that repackages the webjar version of the HAL browser. The repackaging is necessary to be able to control the exposure of the browser dynamically and prevent Spring Boot's auto-exposure of webjars from kicking in. Tweaked BasePathAwareHandlerMapping to default the Accept header to the one defined in the configuration if none is present in the request or */* is given. This will make sure we default to a JSON dialect in case no header is set. --- pom.xml | 1 + spring-data-rest-hal-browser/pom.xml | 95 +++++++++++++ .../rest/webmvc/halbrowser/HalBrowser.java | 76 +++++++++++ .../HalBrowserIntegrationTests.java | 129 ++++++++++++++++++ .../src/test/resources/logback.xml | 16 +++ .../webmvc/BasePathAwareHandlerMapping.java | 77 +++++++++++ .../RepositoryRestMvcConfiguration.java | 23 ++++ 7 files changed, 417 insertions(+) create mode 100644 spring-data-rest-hal-browser/pom.xml create mode 100644 spring-data-rest-hal-browser/src/main/java/org/springframework/data/rest/webmvc/halbrowser/HalBrowser.java create mode 100644 spring-data-rest-hal-browser/src/test/java/org/springframework/data/rest/webmvc/halbrowser/HalBrowserIntegrationTests.java create mode 100644 spring-data-rest-hal-browser/src/test/resources/logback.xml diff --git a/pom.xml b/pom.xml index 156f2441a..4ecb13f01 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,7 @@ spring-data-rest-core spring-data-rest-webmvc spring-data-rest-distribution + spring-data-rest-hal-browser diff --git a/spring-data-rest-hal-browser/pom.xml b/spring-data-rest-hal-browser/pom.xml new file mode 100644 index 000000000..934d07c1c --- /dev/null +++ b/spring-data-rest-hal-browser/pom.xml @@ -0,0 +1,95 @@ + + 4.0.0 + + + org.springframework.data + spring-data-rest-parent + 2.4.0.BUILD-SNAPSHOT + + spring-data-rest-hal-browser + + Spring Data REST - HAL Browser + + + b7669f1-1 + + + + + + ${project.groupId} + spring-data-rest-webmvc + ${project.version} + + + + javax.servlet + javax.servlet-api + 3.0.1 + test + + + + org.webjars + hal-browser + b7669f1-1 + provided + + + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 2.10 + + + unpack + process-resources + + unpack + + + + + org.webjars + hal-browser + + + ${project.build.directory}/hal-browser + + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 1.7 + + + process-resources + + run + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spring-data-rest-hal-browser/src/main/java/org/springframework/data/rest/webmvc/halbrowser/HalBrowser.java b/spring-data-rest-hal-browser/src/main/java/org/springframework/data/rest/webmvc/halbrowser/HalBrowser.java new file mode 100644 index 000000000..0626e484e --- /dev/null +++ b/spring-data-rest-hal-browser/src/main/java/org/springframework/data/rest/webmvc/halbrowser/HalBrowser.java @@ -0,0 +1,76 @@ +/* + * Copyright 2015 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.halbrowser; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.rest.core.config.RepositoryRestConfiguration; +import org.springframework.data.rest.webmvc.BasePathAwareController; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.view.RedirectView; + +/** + * Controller with a few convenience redirects to expose the HAL browser shipped as static content. + * + * @author Oliver Gierke + * @soundtrack Miles Davis - So what (Kind of blue) + */ +@BasePathAwareController +public class HalBrowser { + + private static String BROWSER = "/browser"; + public static String BROWSER_INDEX = BROWSER.concat("/index.html"); + + private final RepositoryRestConfiguration configuration; + + /** + * Creates a new {@link HalBrowser} for the given {@link RepositoryRestConfiguration}. + * + * @param configuration must not be {@literal null}. + */ + @Autowired + public HalBrowser(RepositoryRestConfiguration configuration) { + + Assert.notNull(configuration, "RepositoryRestConfiguration must not be null!"); + + this.configuration = configuration; + } + + /** + * Redirects requests to the API root asking for HTML to the HAL browser. + * + * @return + */ + @RequestMapping(value = { "/", "" }, method = RequestMethod.GET, produces = MediaType.TEXT_HTML_VALUE) + public View index() { + return browser(); + } + + /** + * Redirects to the actual {@code index.html}. + * + * @return + */ + @RequestMapping(value = "/browser", method = RequestMethod.GET) + public View browser() { + + String basePath = configuration.getBasePath().toString(); + return new RedirectView(basePath.concat(BROWSER_INDEX).concat("#").concat(basePath)); + } +} diff --git a/spring-data-rest-hal-browser/src/test/java/org/springframework/data/rest/webmvc/halbrowser/HalBrowserIntegrationTests.java b/spring-data-rest-hal-browser/src/test/java/org/springframework/data/rest/webmvc/halbrowser/HalBrowserIntegrationTests.java new file mode 100644 index 000000000..bb5001e62 --- /dev/null +++ b/spring-data-rest-hal-browser/src/test/java/org/springframework/data/rest/webmvc/halbrowser/HalBrowserIntegrationTests.java @@ -0,0 +1,129 @@ +/* + * Copyright 2015 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.halbrowser; + +import static org.hamcrest.CoreMatchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.rest.core.config.RepositoryRestConfiguration; +import org.springframework.data.rest.webmvc.config.RepositoryRestMvcConfiguration; +import org.springframework.hateoas.MediaTypes; +import org.springframework.http.HttpHeaders; +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.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +/** + * Integration tests for {@link HalBrowser}. + * + * @author Oliver Gierke + * @soundtrack Miles Davis - Blue in green (Kind of blue) + */ +@RunWith(SpringJUnit4ClassRunner.class) +@WebAppConfiguration +@ContextConfiguration +public class HalBrowserIntegrationTests { + + static final String BASE_PATH = "/api"; + static final String BROWSER_INDEX = "/browser/index.html"; + static final String TARGET = BASE_PATH.concat(BROWSER_INDEX).concat("#").concat(BASE_PATH); + + @Configuration + @EnableWebMvc + static class TestConfiguration extends RepositoryRestMvcConfiguration { + + @Override + protected void configureRepositoryRestConfiguration(RepositoryRestConfiguration config) { + config.setBasePath(BASE_PATH); + } + } + + @Autowired WebApplicationContext context; + + MockMvc mvc; + + @Before + public void setUp() { + this.mvc = MockMvcBuilders.webAppContextSetup(context).// + defaultRequest(get(BASE_PATH).accept(MediaType.TEXT_HTML)).build(); + } + + /** + * @see DATAREST-293 + */ + @Test + public void exposesJsonUnderApiRootByDefault() throws Exception { + + mvc.perform(get(BASE_PATH).accept(MediaType.ALL)).// + andExpect(status().isOk()).// + andExpect(header().string(HttpHeaders.CONTENT_TYPE, is(MediaTypes.HAL_JSON.toString()))); + } + + /** + * @see DATAREST-293 + */ + @Test + public void redirectsToBrowserForApiRootAndHtml() throws Exception { + + mvc.perform(get(BASE_PATH).accept(MediaType.TEXT_HTML)).// + andExpect(status().isFound()).// + andExpect(header().string(HttpHeaders.LOCATION, endsWith(TARGET))); + } + + /** + * @see DATAREST-293 + */ + @Test + public void forwardsBrowserToIndexHtml() throws Exception { + + mvc.perform(get(BASE_PATH.concat("/browser"))).// + andExpect(status().isFound()).// + andExpect(header().string(HttpHeaders.LOCATION, endsWith(TARGET))); + } + + /** + * @see DATAREST-293 + */ + @Test + public void exposesHalBrowser() throws Exception { + + mvc.perform(get(BASE_PATH.concat("/browser/index.html"))).// + andExpect(status().isOk()).// + andExpect(content().string(containsString("The HAL Browser"))); + } + + /** + * @see DATAREST-293 + */ + @Test + public void retrunsApiIfHtmlIsNotExplicitlyListed() throws Exception { + + mvc.perform(get(BASE_PATH).accept(MediaType.APPLICATION_JSON, MediaType.ALL)).// + andExpect(status().isOk()).// + andExpect(header().string(HttpHeaders.CONTENT_TYPE, startsWith(MediaType.APPLICATION_JSON_VALUE))); + } +} diff --git a/spring-data-rest-hal-browser/src/test/resources/logback.xml b/spring-data-rest-hal-browser/src/test/resources/logback.xml new file mode 100644 index 000000000..7a648be10 --- /dev/null +++ b/spring-data-rest-hal-browser/src/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + + + + \ No newline at end of file diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/BasePathAwareHandlerMapping.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/BasePathAwareHandlerMapping.java index 5c315d277..76b464205 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/BasePathAwareHandlerMapping.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/BasePathAwareHandlerMapping.java @@ -21,8 +21,10 @@ import java.io.UnsupportedEncodingException; import java.lang.reflect.Method; import java.net.URI; import java.security.Principal; +import java.util.ArrayList; import java.util.Collection; import java.util.Enumeration; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; @@ -37,12 +39,17 @@ import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import javax.servlet.http.Part; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition; import org.springframework.web.servlet.mvc.method.RequestMappingInfo; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; @@ -72,6 +79,36 @@ public class BasePathAwareHandlerMapping extends RequestMappingHandlerMapping { this.configuration = configuration; } + /* + * (non-Javadoc) + * @see org.springframework.web.servlet.handler.AbstractHandlerMethodMapping#lookupHandlerMethod(java.lang.String, javax.servlet.http.HttpServletRequest) + */ + @Override + protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception { + + List mediaTypes = new ArrayList(); + boolean defaultFound = false; + + for (MediaType mediaType : MediaType.parseMediaTypes(request.getHeader(HttpHeaders.ACCEPT))) { + + MediaType rawtype = mediaType.removeQualityValue(); + + if (rawtype.equals(configuration.getDefaultMediaType())) { + defaultFound = true; + } + + if (!rawtype.equals(MediaType.ALL)) { + mediaTypes.add(mediaType); + } + } + + if (!defaultFound) { + mediaTypes.add(configuration.getDefaultMediaType()); + } + + return super.lookupHandlerMethod(lookupPath, new CustomAcceptHeaderHttpServletRequest(request, mediaTypes)); + } + /* * (non-Javadoc) * @see org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping#getMappingForMethod(java.lang.reflect.Method, java.lang.Class) @@ -495,4 +532,44 @@ public class BasePathAwareHandlerMapping extends RequestMappingHandlerMapping { throw new UnsupportedOperationException(); } } + + /** + * {@link HttpServletRequest} that exposes the given media types for the {@code Accept} header. + * + * @author Oliver Gierke + */ + static class CustomAcceptHeaderHttpServletRequest extends HttpServletRequestWrapper { + + private final List acceptMediaTypes; + + /** + * Creates a new {@link CustomAcceptHeaderHttpServletRequest} for the given delegate {@link HttpServletRequest} and + * the list of {@link MediaType}. + * + * @param request must not be {@literal null}. + * @param acceptMediaTypes must not be {@literal null} or empty. + */ + public CustomAcceptHeaderHttpServletRequest(HttpServletRequest request, List acceptMediaTypes) { + + super(request); + + Assert.notEmpty(acceptMediaTypes, "MediaTypes must not be empty!"); + + this.acceptMediaTypes = acceptMediaTypes; + } + + /* + * (non-Javadoc) + * @see javax.servlet.http.HttpServletRequestWrapper#getHeader(java.lang.String) + */ + @Override + public String getHeader(String name) { + + if (HttpHeaders.ACCEPT.equalsIgnoreCase(name) && acceptMediaTypes != null) { + return StringUtils.collectionToCommaDelimitedString(acceptMediaTypes); + } + + return super.getHeader(name); + } + } } 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 0132f9feb..a34973109 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 @@ -119,6 +119,7 @@ import org.springframework.util.ClassUtils; import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; @@ -723,6 +724,28 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon return new AlpsResourceProcessor(config()); } + // + // HAL Browser + // + + /* + * (non-Javadoc) + * @see org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter#addResourceHandlers(org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry) + */ + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + + // Register HAL browser if present + + if (ClassUtils.isPresent("org.springframework.data.rest.webmvc.halbrowser.HalBrowser", getClass().getClassLoader())) { + + String basePath = config().getBasePath().toString().concat("/browser"); + String rootLocation = "classpath:META-INF/spring-data-rest/hal-browser/"; + + registry.addResourceHandler(basePath.concat("/**")).addResourceLocations(rootLocation); + } + } + private static class ResourceSupportHttpMessageConverter extends TypeConstrainedMappingJackson2HttpMessageConverter implements Ordered {