Configure WebSocket transport with GraphiQL

This commit enhances the GraphiQL `index.html` to rely in the
graphiql-tools fetcher function; this supports both HTTP and WebSocket
transports.

As a follow-up, this also updates the `GraphiQlHandler` implementations
to also send a `wsPath` query param if a specific path has been
configured for the WebSocket support.

As an additional change, this commit also improves the fix for gh-231 as
it was incomplete: the generated query params were not taking the
context path into account.

Closes gh-131
Closes gh-231
This commit is contained in:
Brian Clozel
2022-01-10 17:05:23 +01:00
parent 8c655cb25d
commit 10abd919ec
5 changed files with 101 additions and 78 deletions

View File

@@ -23,8 +23,10 @@ import reactor.core.publisher.Mono;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.util.UriBuilder;
/**
* Spring WebFlux handler to serve a GraphiQl UI page.
@@ -37,6 +39,8 @@ public class GraphiQlHandler {
private final String graphQlPath;
private final String graphQlWsPath;
private final Resource htmlResource;
@@ -44,18 +48,21 @@ public class GraphiQlHandler {
* Constructor that serves the default {@code graphiql/index.html} included
* in the {@code spring-graphql} module.
* @param graphQlPath the path to the GraphQL endpoint
* @param graphQlWsPath optional path to the GraphQL WebSocket endpoint
*/
public GraphiQlHandler(String graphQlPath) {
this(graphQlPath, new ClassPathResource("graphiql/index.html"));
public GraphiQlHandler(String graphQlPath, String graphQlWsPath) {
this(graphQlPath, graphQlWsPath, new ClassPathResource("graphiql/index.html"));
}
/**
* Constructor with the HTML page to serve.
* @param graphQlPath the path to the GraphQL endpoint
* @param graphQlWsPath optional path to the GraphQL WebSocket endpoint
* @param htmlResource the GraphiQL page to serve
*/
public GraphiQlHandler(String graphQlPath, Resource htmlResource) {
public GraphiQlHandler(String graphQlPath, String graphQlWsPath, Resource htmlResource) {
this.graphQlPath = graphQlPath;
this.graphQlWsPath = graphQlWsPath;
this.htmlResource = htmlResource;
}
@@ -71,7 +78,20 @@ public class GraphiQlHandler {
}
private URI getRedirectUrl(ServerRequest request) {
return request.uriBuilder().queryParam("path", this.graphQlPath).build();
UriBuilder builder = request.uriBuilder();
String pathQueryParam = applyContextPath(request, this.graphQlPath);
builder.queryParam("path", pathQueryParam);
if (StringUtils.hasText(this.graphQlWsPath)) {
String wsPathQueryParam = applyContextPath(request, this.graphQlWsPath);
builder.queryParam("wsPath", wsPathQueryParam);
}
return builder.build();
}
private String applyContextPath(ServerRequest request, String path) {
String contextPath = request.requestPath().contextPath().toString();
return StringUtils.hasText(contextPath) ? contextPath + path : path;
}
}

View File

@@ -21,8 +21,11 @@ import java.net.URI;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import org.springframework.web.util.UriBuilder;
/**
* Spring MVC handler to serve a GraphiQl UI page.
@@ -35,25 +38,31 @@ public class GraphiQlHandler {
private final String graphQlPath;
private final String graphQlWsPath;
private final Resource htmlResource;
/**
* Constructor that serves the default {@code graphiql/index.html} included
* in the {@code spring-graphql} module.
* @param graphQlPath the path to the GraphQL endpoint
* @param graphQlPath the path to the GraphQL HTTP endpoint
* @param graphQlWsPath optional path to the GraphQL WebSocket endpoint
*/
public GraphiQlHandler(String graphQlPath) {
this(graphQlPath, new ClassPathResource("graphiql/index.html"));
public GraphiQlHandler(String graphQlPath, String graphQlWsPath) {
this(graphQlPath, graphQlWsPath, new ClassPathResource("graphiql/index.html"));
}
/**
* Constructor with the HTML page to serve.
* @param graphQlPath the path to the GraphQL endpoint
* @param graphQlPath the path to the GraphQL HTTP endpoint
* @param graphQlWsPath optional path to the GraphQL WebSocket endpoint
* @param htmlResource the GraphiQL page to serve
*/
public GraphiQlHandler(String graphQlPath, Resource htmlResource) {
public GraphiQlHandler(String graphQlPath, String graphQlWsPath, Resource htmlResource) {
Assert.hasText(graphQlPath, "graphQlPath should not be empty");
this.graphQlPath = graphQlPath;
this.graphQlWsPath = graphQlWsPath;
this.htmlResource = htmlResource;
}
@@ -71,7 +80,21 @@ public class GraphiQlHandler {
private URI getRedirectUrl(ServerRequest request) {
String contextPath = request.requestPath().contextPath().toString();
String path = request.requestPath().pathWithinApplication().toString();
return request.uriBuilder().replacePath(contextPath).path(path).queryParam("path", this.graphQlPath).build();
UriBuilder builder = request.uriBuilder().replacePath(contextPath).path(path);
String pathQueryParam = applyContextPath(request, this.graphQlPath);
builder.queryParam("path", pathQueryParam);
if (StringUtils.hasText(this.graphQlWsPath)) {
String wsPathQueryParam = applyContextPath(request, this.graphQlWsPath);
builder.queryParam("wsPath", wsPathQueryParam);
}
return builder.build();
}
private String applyContextPath(ServerRequest request, String path) {
String contextPath = request.requestPath().contextPath().toString();
return StringUtils.hasText(contextPath) ? contextPath + path : path;
}
}

View File

@@ -1,18 +1,3 @@
<!--
~ Copyright 2002-2020 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.
-->
<!DOCTYPE html>
<html>
<head>
@@ -28,61 +13,29 @@
height: 100vh;
}
</style>
<!--
This GraphiQL example depends on Promise and fetch, which are available in
modern browsers, but can be "polyfilled" for older browsers.
GraphiQL itself depends on React DOM.
If you do not want to rely on a CDN, you can host these files locally or
include them directly in your favored resource bunder.
-->
<script
crossorigin
src="https://unpkg.com/react@16/umd/react.development.js"
></script>
<script
crossorigin
src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"
></script>
<!--
These two files can be found in the npm module, however you may wish to
copy them directly into your environment, or perhaps include them in your
favored resource bundler.
-->
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<link rel="stylesheet" href="https://unpkg.com/graphiql/graphiql.min.css" />
</head>
<body>
<div id="graphiql">Loading...</div>
<script
src="https://unpkg.com/graphiql/graphiql.min.js"
type="application/javascript"
></script>
<script src="https://unpkg.com/graphiql/graphiql.min.js" type="application/javascript"></script>
<script>
function graphQLFetcher(path) {
return (graphQLParams, options = {}) => fetch(
`${location.protocol}//${location.host}${path}`,
{
method: 'post',
headers: {
...options.headers,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(graphQLParams),
credentials: 'omit',
},
).then(function (response) {
return response.json().catch(function () {
return response.text();
});
});
}
const params = new URLSearchParams(window.location.search);
const path = params.get("path") || "/graphiql";
const gqlFetcher = graphQLFetcher(path);
const path = params.get("path") || "/graphql";
const url = `${location.protocol}//${location.host}${path}`;
const wsPath = params.get("wsPath") || "/graphql";
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const subscriptionUrl = `${wsProtocol}//${location.host}${wsPath}`;
const gqlFetcher = GraphiQL.createFetcher({
url,
subscriptionUrl,
});
ReactDOM.render(
React.createElement(GraphiQL, {
fetcher: gqlFetcher,

View File

@@ -45,7 +45,7 @@ class GraphiQlHandlerTests {
private static final List<HttpMessageReader<?>> MESSAGE_READERS = Collections.emptyList();
private final GraphiQlHandler handler = new GraphiQlHandler("/graphql",
private final GraphiQlHandler handler = new GraphiQlHandler("/graphql", null,
new ByteArrayResource("GRAPHIQL".getBytes(StandardCharsets.UTF_8)));
@@ -60,6 +60,19 @@ class GraphiQlHandlerTests {
assertThat(response.headers().getLocation().toASCIIString()).isEqualTo("/graphiql?path=/graphql");
}
@Test
void shouldRedirectWithPathAndWsPathQueryParameter() {
GraphiQlHandler wsHandler = new GraphiQlHandler("/graphql", "/graphql",
new ByteArrayResource("GRAPHIQL".getBytes(StandardCharsets.UTF_8)));
MockServerHttpRequest httpRequest = MockServerHttpRequest.get("/graphiql").build();
MockServerWebExchange exchange = MockServerWebExchange.from(httpRequest);
ServerRequest request = ServerRequest.create(exchange, MESSAGE_READERS);
ServerResponse response = wsHandler.handleRequest(request).block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.TEMPORARY_REDIRECT);
assertThat(response.headers().getLocation()).isNotNull();
assertThat(response.headers().getLocation().toASCIIString()).isEqualTo("/graphiql?path=/graphql&wsPath=/graphql");
}
@Test
void shouldServeGraphiQlHtmlResource() {
MockServerHttpRequest httpRequest = MockServerHttpRequest.get("/graphiql").queryParam("path", "/graphql").build();
@@ -82,7 +95,7 @@ class GraphiQlHandlerTests {
ServerResponse response = this.handler.handleRequest(request).block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.TEMPORARY_REDIRECT);
assertThat(response.headers().getLocation()).isNotNull();
assertThat(response.headers().getLocation().toASCIIString()).isEqualTo("/context/graphiql?path=/graphql");
assertThat(response.headers().getLocation().toASCIIString()).isEqualTo("/context/graphiql?path=/context/graphql");
}
private String getResponseContent(MockServerWebExchange exchange, ServerResponse response) {

View File

@@ -47,7 +47,7 @@ class GraphiQlHandlerTests {
private static final List<HttpMessageConverter<?>> MESSAGE_READERS = Collections.emptyList();
private GraphiQlHandler handler = new GraphiQlHandler("/graphql",
private GraphiQlHandler handler = new GraphiQlHandler("/graphql", null,
new ByteArrayResource("GRAPHIQL".getBytes(StandardCharsets.UTF_8)));
@Test
@@ -60,6 +60,18 @@ class GraphiQlHandlerTests {
assertThat(response.headers().getLocation().toASCIIString()).isEqualTo("http://localhost/graphiql?path=/graphql");
}
@Test
void shouldRedirectWithPathAndWsPathQueryParameter() {
GraphiQlHandler wsHandler = new GraphiQlHandler("/graphql", "/graphql",
new ByteArrayResource("GRAPHIQL".getBytes(StandardCharsets.UTF_8)));
MockHttpServletRequest servletRequest = new MockHttpServletRequest("GET", "/graphiql");
ServerRequest request = ServerRequest.create(servletRequest, MESSAGE_READERS);
ServerResponse response = wsHandler.handleRequest(request);
assertThat(response.statusCode()).isEqualTo(HttpStatus.TEMPORARY_REDIRECT);
assertThat(response.headers().getLocation()).isNotNull();
assertThat(response.headers().getLocation().toASCIIString()).isEqualTo("http://localhost/graphiql?path=/graphql&wsPath=/graphql");
}
@Test
void shouldServeGraphiQlHtmlResource() throws Exception {
MockHttpServletRequest servletRequest = new MockHttpServletRequest("GET", "/graphiql");
@@ -73,13 +85,15 @@ class GraphiQlHandlerTests {
@Test
void shouldConsiderContextPathWhenRedirecting() {
GraphiQlHandler wsHandler = new GraphiQlHandler("/graphql", "/graphql",
new ByteArrayResource("GRAPHIQL".getBytes(StandardCharsets.UTF_8)));
MockHttpServletRequest servletRequest = new MockHttpServletRequest("GET", "/context/graphiql");
servletRequest.setContextPath("/context");
ServerRequest request = ServerRequest.create(servletRequest, MESSAGE_READERS);
ServerResponse response = this.handler.handleRequest(request);
ServerResponse response = wsHandler.handleRequest(request);
assertThat(response.statusCode()).isEqualTo(HttpStatus.TEMPORARY_REDIRECT);
assertThat(response.headers().getLocation()).isNotNull();
assertThat(response.headers().getLocation().toASCIIString()).isEqualTo("http://localhost/context/graphiql?path=/graphql");
assertThat(response.headers().getLocation().toASCIIString()).isEqualTo("http://localhost/context/graphiql?path=/context/graphql&wsPath=/context/graphql");
}
private String getResponseContent(MockHttpServletRequest servletRequest, ServerResponse response) throws ServletException, IOException {