DATAREST-1386 - Add HAL Explorer module

We now ship a module containing the HAL Explorer [0] as an alternative to the HAL Browser which seems to be mostly out of maintenance and the WebJar refers to a jQuery version with a CVE. The new module still contains some customization code that we had in place for the browser to customize the POST/PUT forms to use the JSON Schema exposed for the aggregates.

The HAL Browser extension is now deprecated in the form that its inclusion causes a warn log to recommend switching to the explorer. A couple of general coding improvements: package scope for controllers and handler methods. Better use of constants and newer request mapping annotations.

Tweaked the configuration of static resource handlers to resolve the implicit cyclic dependency between the browser/explorer and the WebMVC module by introducing a Spring Factories backed extension SPI.

[0] https://github.com/toedter/hal-explorer
This commit is contained in:
Oliver Drotbohm
2019-06-05 11:09:44 +02:00
parent 49605b2b1a
commit e744c1c94f
15 changed files with 842 additions and 21 deletions

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2015-2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.halexplorer;
import javax.servlet.http.HttpServletRequest;
import org.springframework.data.rest.webmvc.BasePathAwareController;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.servlet.view.RedirectView;
import org.springframework.web.util.UriComponents;
/**
* Controller with a few convenience redirects to expose the HAL explorer shipped as static content.
*
* @author Oliver Gierke
* @soundtrack Miles Davis - So what (Kind of blue)
*/
@BasePathAwareController
class HalExplorer {
static final String EXPLORER = "/explorer";
static final String INDEX = "/index.html";
/**
* Redirects requests to the API root asking for HTML to the HAL explorer.
*
* @return
*/
@GetMapping(value = { "/", "" }, produces = MediaType.TEXT_HTML_VALUE)
View index(HttpServletRequest request) {
return getRedirectView(request, false);
}
/**
* Redirects to the actual {@code index.html}.
*
* @return
*/
@GetMapping(value = EXPLORER)
public View explorer(HttpServletRequest request) {
return getRedirectView(request, request.getRequestURI().endsWith(EXPLORER));
}
/**
* Returns the View to redirect to to access the HAL explorer.
*
* @param request must not be {@literal null}.
* @param explorerRelative
* @return
*/
private View getRedirectView(HttpServletRequest request, boolean explorerRelative) {
ServletUriComponentsBuilder builder = ServletUriComponentsBuilder.fromRequest(request);
UriComponents components = builder.build();
String path = components.getPath() == null ? "" : components.getPath();
if (!explorerRelative) {
builder.path(EXPLORER);
}
builder.path(INDEX);
builder.fragment(explorerRelative ? path.substring(0, path.lastIndexOf(EXPLORER)) : path);
return new RedirectView(builder.build().toUriString());
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.halexplorer;
import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
import org.springframework.data.rest.webmvc.config.StaticResourceProvider;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
/**
* {@link StaticResourceProvider} to expose the HAL Browser WebJar content via a static resource route.
*
* @author Oliver Drotbohm
* @since 3.2
* @soundtrack Tedeschi Trucks Band - Signs, High Times (Signs)
*/
class HalExplorerConfiguration implements StaticResourceProvider {
/*
* (non-Javadoc)
* @see org.springframework.data.rest.webmvc.config.StaticResourceProvider#customizeResources(org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry, org.springframework.data.rest.core.config.RepositoryRestConfiguration)
*/
public void customizeResources(ResourceHandlerRegistry registry, RepositoryRestConfiguration configuration) {
String basePath = configuration.getBasePath().toString().concat(HalExplorer.EXPLORER);
String rootLocation = "classpath:META-INF/spring-data-rest/hal-explorer/";
registry.addResourceHandler(basePath.concat("/**")).addResourceLocations(rootLocation);
}
}

View File

@@ -0,0 +1,203 @@
/**
* Custom Backbone view that uses JSON Schema metadata to create pop-up dialog with actual field names instead of
* asking user to input raw JSON.
*
* NOTE: Because JSON Schema lists all properties, including those that are links, they have to be filtered out.
* Links have to be set via a PUT operation with the proper media type.
*
* @author Greg Turnquist
* @author Gregory Frank
* @since 2.4
* @see DATAREST-627, DATAREST-1077
*/
/* jshint strict: true */
/* globals HAL, Backbone, _, $, window, jqxhr */
'use strict';
var CustomPostForm = Backbone.View.extend({
initialize: function (opts) {
this.href = opts.href.split('{')[0];
this.vent = opts.vent;
_.bindAll(this, 'createNewResource');
},
events: {
'submit form': 'createNewResource'
},
className: 'modal fade',
/**
* Perform a POST/PUT operation on the resource.
*
* @param e
*/
createNewResource: function (e) {
e.preventDefault();
var self = this;
var opts = {
url: this.$('.url').val(),
headers: _.defaults({'Content-Type': 'application/json'}, HAL.client.getHeaders()),
method: this.$('.method').val(),
data: this.getNewResourceData()
};
HAL.client.request(opts).done(function (response) {
self.vent.trigger('response', {resource: response, jqxhr: jqxhr});
}).fail(function (e) {
self.vent.trigger('fail-response', {jqxhr: jqxhr});
}).always(function (e) {
self.vent.trigger('response-headers', {jqxhr: jqxhr});
window.location.hash = 'NON-GET:' + opts.url;
});
this.$el.modal('hide');
},
/**
* Draw the dialog after fetching the resource's JSON Schema metadata. If no metadata is available, use the
* fallback editor.
*
* @param opts
* @returns {CustomPostForm}
*/
render: function (opts) {
var self = this;
try {
HAL.client.request({
method: 'HEAD',
headers: HAL.client.getHeaders(),
url: this.href
}).done(function (message, text, jqXHR) {
self.$el.html(self.template({href: self.href}));
try {
var hal = self.w3cLinksToHalLinks(jqXHR.getResponseHeader('Link'));
HAL.client.request({
method: 'GET',
url: hal._links.profile.href,
headers: _.defaults({'Accept': 'application/schema+json'}, HAL.client.getHeaders())
}).done(function (schema) {
self.loadJsonEditor(schema);
});
} catch (e) {
self.loadFallbackEditor();
}
self.$el.modal();
});
} catch (e) {
self.loadFallbackEditor();
self.$el.modal();
}
return this;
},
/**
* Load the JSON Schema-driven editor.
*
* @see https://github.com/jdorn/json-editor
*/
loadJsonEditor: function (schema) {
var self = this;
/**
* Remove URI-based fields since this dialog doesn't handle relationships.
*/
Object.keys(schema.properties).forEach(function (property) {
if (schema.properties[property].hasOwnProperty('format') &&
schema.properties[property].format === 'uri') {
delete schema.properties[property];
}
});
/**
* See https://github.com/jdorn/json-editor#options for more customizing options.
*/
this.editor = new window.JSONEditor(this.$('#jsoneditor')[0], {
theme: 'bootstrap2',
schema: schema,
disable_collapse: true,
disable_edit_json: true,
disable_properties: true
});
this.getNewResourceData = function() {
return JSON.stringify(self.editor.getValue());
}
},
/**
* Load fallback editor that doesn't depend on any form of metadata.
*/
loadFallbackEditor: function () {
var editor = this.$('#jsoneditor');
editor.append($('<h4>' + this.href + '</h4>'));
var inputBox = $('<textarea name="body" class="body" style="height: 200px">{\n}</textarea>');
editor.append(inputBox);
this.getNewResourceData = function() {
return inputBox.val();
}
},
/**
* Convert a W3C link header into a HAL-based set of _links.
*
* e.g.
* <http://localhost:8080/persons>; rel="persons",<http://localhost:8080/profile/persons>; rel="profile"
* to
* {
* _links: {
* persons: {
* href: http://localhost:8080/persons
* },
* profile: {
* href: http://localhost:8080/profile/persons
* }
* }
* }
*
* @param linkHeader - HTTP Response header containing a list of W3C compliant links with rels.
* @see http://www.w3.org/wiki/LinkHeader
*/
w3cLinksToHalLinks: function (linkHeader) {
var w3cLinks = linkHeader.split(',');
var halLinks = {_links: {}};
w3cLinks.forEach(function (w3cLink) {
var parts = w3cLink.split(';');
var hrefWrappedWithBrackets = parts[0];
var href = hrefWrappedWithBrackets.slice(1, parts[0].length - 1);
var w3cRel = parts[1];
var relWrappedWithQuotes = w3cRel.split('=')[1];
var rel = relWrappedWithQuotes.slice(1, relWrappedWithQuotes.length - 1);
halLinks._links[rel] = { "href": href };
});
return halLinks;
},
/**
* Look up the HTML template.
*/
template: _.template($('#dynamic-request-template').html())
});
/**
* Inject the form into the HAL Browser.
*/
HAL.customPostForm = CustomPostForm;

View File

@@ -0,0 +1 @@
org.springframework.data.rest.webmvc.config.StaticResourceProvider=org.springframework.data.rest.webmvc.halexplorer.HalExplorerConfiguration

View File

@@ -0,0 +1,122 @@
/*
* Copyright 2015-2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.halexplorer;
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.ObjectFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.ConversionService;
import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer;
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 HalExplorer}.
*
* @author Oliver Gierke
* @soundtrack Miles Davis - Blue in green (Kind of blue)
*/
@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration
public class HalExplorerIntegrationTests {
static final String BASE_PATH = "/api";
static final String EXPLORER_INDEX = "/explorer/index.html";
static final String TARGET = BASE_PATH.concat(EXPLORER_INDEX).concat("#").concat(BASE_PATH);
@Configuration
@EnableWebMvc
static class TestConfiguration extends RepositoryRestMvcConfiguration {
public TestConfiguration(ApplicationContext context, ObjectFactory<ConversionService> conversionService) {
super(context, conversionService);
}
@Bean
RepositoryRestConfigurer configExtension() {
return RepositoryRestConfigurer.withConfig(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();
}
@Test // DATAREST-293
public void exposesJsonUnderApiRootByDefault() throws Exception {
mvc.perform(get(BASE_PATH).accept(MediaType.ALL)).//
andExpect(status().isOk()).//
andExpect(header().string(HttpHeaders.CONTENT_TYPE, startsWith(MediaTypes.HAL_JSON.toString())));
}
@Test // DATAREST-293
public void redirectsToBrowserForApiRootAndHtml() throws Exception {
mvc.perform(get(BASE_PATH).accept(MediaType.TEXT_HTML)).//
andExpect(status().isFound()).//
andExpect(header().string(HttpHeaders.LOCATION, endsWith(TARGET)));
}
@Test // DATAREST-293
public void forwardsBrowserToIndexHtml() throws Exception {
mvc.perform(get(BASE_PATH.concat("/explorer"))).//
andExpect(status().isFound()).//
andExpect(header().string(HttpHeaders.LOCATION, endsWith(TARGET)));
}
@Test // DATAREST-293
public void exposesHalBrowser() throws Exception {
mvc.perform(get(BASE_PATH.concat("/explorer/index.html"))).//
andExpect(status().isOk()).//
andExpect(content().string(containsString("HAL Explorer")));
}
@Test // DATAREST-293
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)));
}
}

View File

@@ -0,0 +1,90 @@
/*
* Copyright 2015-2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.halexplorer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.assertThat;
import java.io.IOException;
import java.util.Collections;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import org.junit.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.filter.ForwardedHeaderFilter;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.view.AbstractView;
import org.springframework.web.servlet.view.RedirectView;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Unit tests for {@link HalExplorer}.
*
* @author Oliver Gierke
* @author Mark Paluch
* @soundtrack Nils Wülker - Homeless Diamond (feat. Lauren Flynn)
*/
public class HalExplorerUnitTests {
@Test // DATAREST-565, DATAREST-720
public void createsContextRelativeRedirectForBrowser() throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/context");
request.setContextPath("/context");
View view = new HalExplorer().explorer(request);
assertThat(view).isInstanceOf(RedirectView.class);
((AbstractView) view).render(Collections.<String, Object> emptyMap(), request, response);
UriComponents components = UriComponentsBuilder.fromUriString(response.getHeader(HttpHeaders.LOCATION)).build();
assertThat(components.getPath(), startsWith("/context"));
assertThat(components.getFragment()).isEqualTo("/context");
}
@Test // DATAREST-1264
public void producesProxyRelativeRedirectIfNecessary() throws ServletException, IOException {
MockHttpServletResponse response = new MockHttpServletResponse();
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/explorer");
request.addHeader("X-Forwarded-Host", "somehost");
request.addHeader("X-Forwarded-Port", "4711");
request.addHeader("X-Forwarded-Proto", "https");
request.addHeader("X-Forwarded-Prefix", "/prefix");
ForwardedHeaderFilter filter = new ForwardedHeaderFilter();
filter.doFilter(request, response, (req, resp) -> {
View view = new HalExplorer().explorer((HttpServletRequest) req);
assertThat(view).isInstanceOf(RedirectView.class);
String url = ((RedirectView) view).getUrl();
assertThat(url, startsWith("https://somehost:4711/prefix"));
assertThat(url, endsWith("/prefix"));
});
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d %5p %40.40c:%4L - %m%n</pattern>
</encoder>
</appender>
<logger name="org.springframework" level="warn" />
<root level="error">
<appender-ref ref="console" />
</root>
</configuration>