From 1e1d99d6d720664d888f3da223a03c13c0fa8fbc Mon Sep 17 00:00:00 2001 From: dariuszkuc <9501705+dariuszkuc@users.noreply.github.com> Date: Wed, 29 May 2024 15:21:00 -0500 Subject: [PATCH] fix: update subs callback to SpringBoot 3.3 --- gradle.properties | 6 +- spring-subscription-callback/build.gradle.kts | 7 - .../webflux/CallbackGraphQlHttpHandler.java | 136 +++++++++++----- .../webmvc/CallbackGraphQlHttpHandler.java | 154 +++++++++++++----- 4 files changed, 216 insertions(+), 87 deletions(-) diff --git a/gradle.properties b/gradle.properties index 95d5009e..783b20e3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,12 +3,12 @@ version = 3.0-SNAPSHOT # dependencies annotationsVersion = 24.1.0 -graphQLJavaVersion = 22.0 +graphQLJavaVersion = 22.1 mockWebServerVersion = 4.12.0 protobufVersion = 4.26.1 slf4jVersion = 2.0.13 -springBootVersion = 3.3.0-RC1 -springGraphQLVersion = 1.3.0-RC1 +springBootVersion = 3.3.0 +springGraphQLVersion = 1.3.0 reactorVersion = 3.6.6 # test dependencies diff --git a/spring-subscription-callback/build.gradle.kts b/spring-subscription-callback/build.gradle.kts index 4576e3ee..2fcf2a16 100644 --- a/spring-subscription-callback/build.gradle.kts +++ b/spring-subscription-callback/build.gradle.kts @@ -4,13 +4,6 @@ plugins { id("com.apollographql.federation.java-conventions") } -repositories { - mavenCentral() - maven { - url = uri("https://repo.spring.io/milestone") - } -} - val annotationsVersion: String by project val graphQLJavaVersion: String by project val mockWebServerVersion: String by project diff --git a/spring-subscription-callback/src/main/java/com/apollographql/subscription/webflux/CallbackGraphQlHttpHandler.java b/spring-subscription-callback/src/main/java/com/apollographql/subscription/webflux/CallbackGraphQlHttpHandler.java index 579dba23..912d62d2 100644 --- a/spring-subscription-callback/src/main/java/com/apollographql/subscription/webflux/CallbackGraphQlHttpHandler.java +++ b/spring-subscription-callback/src/main/java/com/apollographql/subscription/webflux/CallbackGraphQlHttpHandler.java @@ -12,13 +12,27 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.jetbrains.annotations.NotNull; +import org.reactivestreams.Publisher; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.graphql.ResponseError; import org.springframework.graphql.server.WebGraphQlHandler; import org.springframework.graphql.server.WebGraphQlRequest; +import org.springframework.graphql.server.WebGraphQlResponse; +import org.springframework.graphql.server.support.SerializableGraphQlRequest; import org.springframework.graphql.server.webflux.GraphQlHttpHandler; +import org.springframework.graphql.support.DefaultExecutionGraphQlResponse; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.codec.CodecConfigurer; +import org.springframework.http.codec.DecoderHttpMessageReader; +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.UnsupportedMediaTypeStatusException; import reactor.core.publisher.Mono; /** @@ -34,19 +48,9 @@ public class CallbackGraphQlHttpHandler extends GraphQlHttpHandler { private static final Log logger = LogFactory.getLog(GraphQlHttpHandler.class); - private static final ParameterizedTypeReference> MAP_PARAMETERIZED_TYPE_REF = - new ParameterizedTypeReference<>() {}; - - private static final MediaType APPLICATION_GRAPHQL_RESPONSE = - MediaType.APPLICATION_GRAPHQL_RESPONSE; - - @SuppressWarnings("removal") - private static final List SUPPORTED_MEDIA_TYPES = - Arrays.asList( - APPLICATION_GRAPHQL_RESPONSE, MediaType.APPLICATION_JSON, MediaType.APPLICATION_GRAPHQL); - private final WebGraphQlHandler graphQlHandler; private final SubscriptionCallbackHandler subscriptionCallbackHandler; + @Nullable private final Decoder decoder; public CallbackGraphQlHttpHandler(WebGraphQlHandler graphQlHandler) { this(graphQlHandler, new SubscriptionCallbackHandler(graphQlHandler)); @@ -54,15 +58,22 @@ public CallbackGraphQlHttpHandler(WebGraphQlHandler graphQlHandler) { public CallbackGraphQlHttpHandler( WebGraphQlHandler graphQlHandler, SubscriptionCallbackHandler subscriptionCallbackHandler) { - super(graphQlHandler); + this(graphQlHandler, subscriptionCallbackHandler, null); + } + + public CallbackGraphQlHttpHandler( + WebGraphQlHandler graphQlHandler, + SubscriptionCallbackHandler subscriptionCallbackHandler, + @Nullable CodecConfigurer codecConfigurer) { + super(graphQlHandler, codecConfigurer); this.graphQlHandler = graphQlHandler; this.subscriptionCallbackHandler = subscriptionCallbackHandler; + this.decoder = (codecConfigurer != null) ? findJsonDecoder(codecConfigurer) : null; } @NotNull public Mono handleRequest(@NotNull ServerRequest serverRequest) { - return serverRequest - .bodyToMono(MAP_PARAMETERIZED_TYPE_REF) + return readRequest(serverRequest) .map( body -> new WebGraphQlRequest( @@ -81,9 +92,9 @@ public Mono handleRequest(@NotNull ServerRequest serverRequest) } // in order to correctly handle parsing of ANY requests (i.e. it is valid to define a - // document with query fragments first) - // we would need to parse it which is a much heavier operation, we may opt to do it in - // the future releases + // document with query fragments first) we would need to parse it which is a much + // heavier + // operation, we may opt to do it in the future releases if (graphQlRequest.getDocument().startsWith("subscription")) { return parseSubscriptionCallbackExtension(graphQlRequest.getExtensions()) .flatMap( @@ -96,14 +107,8 @@ public Mono handleRequest(@NotNull ServerRequest serverRequest) .flatMap( success -> { if (success) { - var emptyResponse = - ExecutionResult.newExecutionResult().data(null).build(); - var builder = ServerResponse.ok(); - builder.header( - SUBSCRIPTION_PROTOCOL_HEADER, - SUBSCRIPTION_PROTOCOL_HEADER_VALUE); - builder.contentType(selectResponseMediaType(serverRequest)); - return builder.bodyValue(emptyResponse.toSpecification()); + return prepareResponse( + serverRequest, emptyResponse(graphQlRequest)); } else { return ServerResponse.badRequest().build(); } @@ -121,25 +126,82 @@ public Mono handleRequest(@NotNull ServerRequest serverRequest) return this.graphQlHandler .handleRequest(graphQlRequest) .flatMap( - response -> { + (response) -> { if (logger.isDebugEnabled()) { - logger.debug("Execution complete"); + List errors = response.getErrors(); + logger.debug( + "Execution result " + + (!CollectionUtils.isEmpty(errors) + ? "has errors: " + errors + : "is ready") + + "."); } - ServerResponse.BodyBuilder builder = ServerResponse.ok(); - builder.headers(headers -> headers.putAll(response.getResponseHeaders())); - builder.contentType(selectResponseMediaType(serverRequest)); - return builder.bodyValue(response.toMap()); + + return prepareResponse(serverRequest, response); }); } }); } - private static MediaType selectResponseMediaType(ServerRequest serverRequest) { - for (MediaType accepted : serverRequest.headers().accept()) { - if (SUPPORTED_MEDIA_TYPES.contains(accepted)) { - return accepted; - } + private WebGraphQlResponse emptyResponse(WebGraphQlRequest request) { + var emptyResponse = ExecutionResult.newExecutionResult().data(null).build(); + var emptyExecutionResponse = + new DefaultExecutionGraphQlResponse(request.toExecutionInput(), emptyResponse); + var emptyGraphQLResponse = new WebGraphQlResponse(emptyExecutionResponse); + emptyGraphQLResponse + .getResponseHeaders() + .add(SUBSCRIPTION_PROTOCOL_HEADER, SUBSCRIPTION_PROTOCOL_HEADER_VALUE); + return emptyGraphQLResponse; + } + + // ========= CODE BELOW COPIED FROM SPRING ============= + // Those are all private static methods so sadly we cannot access them directly :( + private static final ResolvableType REQUEST_TYPE = + ResolvableType.forClass(SerializableGraphQlRequest.class); + + // copy from protected HttpCodecDelegate class + private static Decoder findJsonDecoder(CodecConfigurer configurer) { + return configurer.getReaders().stream() + .filter((reader) -> reader.canRead(REQUEST_TYPE, MediaType.APPLICATION_JSON)) + .map((reader) -> ((DecoderHttpMessageReader) reader).getDecoder()) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No JSON Decoder")); + } + + @SuppressWarnings("unchecked") + Mono decode( + Publisher inputStream, MediaType contentType) { + return (Mono) + this.decoder.decodeToMono(inputStream, REQUEST_TYPE, contentType, null); + } + + // copy from AbstractGraphQlHttpHandler + private Mono readRequest(ServerRequest serverRequest) { + if (this.decoder != null) { + MediaType contentType = + serverRequest.headers().contentType().orElse(MediaType.APPLICATION_JSON); + return decode(serverRequest.bodyToFlux(DataBuffer.class), contentType); + } else { + return serverRequest + .bodyToMono(SerializableGraphQlRequest.class) + .onErrorResume( + UnsupportedMediaTypeStatusException.class, + (ex) -> applyApplicationGraphQlFallback(ex, serverRequest)); } - return MediaType.APPLICATION_JSON; + } + + // copy from AbstractGraphQlHttpHandler + private static Mono applyApplicationGraphQlFallback( + UnsupportedMediaTypeStatusException ex, ServerRequest request) { + + // Spec requires application/json but some clients still use application/graphql + return "application/graphql".equals(request.headers().firstHeader(HttpHeaders.CONTENT_TYPE)) + ? ServerRequest.from(request) + .headers((headers) -> headers.setContentType(MediaType.APPLICATION_JSON)) + .body(request.bodyToFlux(DataBuffer.class)) + .build() + .bodyToMono(SerializableGraphQlRequest.class) + .log() + : Mono.error(ex); } } diff --git a/spring-subscription-callback/src/main/java/com/apollographql/subscription/webmvc/CallbackGraphQlHttpHandler.java b/spring-subscription-callback/src/main/java/com/apollographql/subscription/webmvc/CallbackGraphQlHttpHandler.java index 3f6132c5..1935d0fa 100644 --- a/spring-subscription-callback/src/main/java/com/apollographql/subscription/webmvc/CallbackGraphQlHttpHandler.java +++ b/spring-subscription-callback/src/main/java/com/apollographql/subscription/webmvc/CallbackGraphQlHttpHandler.java @@ -7,20 +7,35 @@ import com.apollographql.subscription.callback.SubscriptionCallbackHandler; import graphql.ExecutionResult; import jakarta.servlet.ServletException; -import java.util.Arrays; +import jakarta.servlet.http.Cookie; +import java.io.IOException; import java.util.List; -import java.util.Map; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.jetbrains.annotations.NotNull; import org.springframework.context.i18n.LocaleContextHolder; -import org.springframework.core.ParameterizedTypeReference; +import org.springframework.graphql.GraphQlRequest; +import org.springframework.graphql.ResponseError; import org.springframework.graphql.server.WebGraphQlHandler; import org.springframework.graphql.server.WebGraphQlRequest; +import org.springframework.graphql.server.WebGraphQlResponse; +import org.springframework.graphql.server.support.SerializableGraphQlRequest; import org.springframework.graphql.server.webmvc.GraphQlHttpHandler; +import org.springframework.graphql.support.DefaultExecutionGraphQlResponse; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.lang.Nullable; import org.springframework.util.AlternativeJdkIdGenerator; +import org.springframework.util.CollectionUtils; import org.springframework.util.IdGenerator; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.server.ServerWebInputException; import org.springframework.web.servlet.function.ServerRequest; import org.springframework.web.servlet.function.ServerResponse; import reactor.core.publisher.Mono; @@ -38,21 +53,11 @@ public class CallbackGraphQlHttpHandler extends GraphQlHttpHandler { private static final Log logger = LogFactory.getLog(GraphQlHttpHandler.class); - private static final ParameterizedTypeReference> MAP_PARAMETERIZED_TYPE_REF = - new ParameterizedTypeReference<>() {}; - - private static final MediaType APPLICATION_GRAPHQL_RESPONSE = - MediaType.APPLICATION_GRAPHQL_RESPONSE; - - @SuppressWarnings("removal") - private static final List SUPPORTED_MEDIA_TYPES = - Arrays.asList( - APPLICATION_GRAPHQL_RESPONSE, MediaType.APPLICATION_JSON, MediaType.APPLICATION_GRAPHQL); - private final IdGenerator idGenerator = new AlternativeJdkIdGenerator(); private final WebGraphQlHandler graphQlHandler; private final SubscriptionCallbackHandler subscriptionCallbackHandler; + @Nullable private final HttpMessageConverter messageConverter; public CallbackGraphQlHttpHandler(WebGraphQlHandler graphQlHandler) { this(graphQlHandler, new SubscriptionCallbackHandler(graphQlHandler)); @@ -60,9 +65,18 @@ public CallbackGraphQlHttpHandler(WebGraphQlHandler graphQlHandler) { public CallbackGraphQlHttpHandler( WebGraphQlHandler graphQlHandler, SubscriptionCallbackHandler subscriptionCallbackHandler) { - super(graphQlHandler); + this(graphQlHandler, subscriptionCallbackHandler, null); + } + + @SuppressWarnings("unchecked") + public CallbackGraphQlHttpHandler( + WebGraphQlHandler graphQlHandler, + SubscriptionCallbackHandler subscriptionCallbackHandler, + @Nullable HttpMessageConverter converter) { + super(graphQlHandler, converter); this.graphQlHandler = graphQlHandler; this.subscriptionCallbackHandler = subscriptionCallbackHandler; + this.messageConverter = (HttpMessageConverter) converter; } @Override @@ -84,9 +98,8 @@ public CallbackGraphQlHttpHandler( } // in order to correctly handle parsing of ANY requests (i.e. it is valid to define a document - // with query fragments first) - // we would need to parse it which is a much heavier operation, we may opt to do it in the - // future releases + // with query fragments first) we would need to parse it which is a much heavier operation, we + // may opt to do it in the future releases if (graphQlRequest.getDocument().startsWith("subscription")) { Mono responseMono = parseSubscriptionCallbackExtension(graphQlRequest.getExtensions()) @@ -100,14 +113,8 @@ public CallbackGraphQlHttpHandler( .map( success -> { if (success) { - var emptyResponse = - ExecutionResult.newExecutionResult().data(null).build(); - var builder = ServerResponse.ok(); - builder.header( - SUBSCRIPTION_PROTOCOL_HEADER, - SUBSCRIPTION_PROTOCOL_HEADER_VALUE); - builder.contentType(selectResponseMediaType(serverRequest)); - return builder.body(emptyResponse.toSpecification()); + return prepareResponse( + serverRequest, emptyResponse(graphQlRequest)); } else { return ServerResponse.badRequest().build(); } @@ -122,30 +129,97 @@ public CallbackGraphQlHttpHandler( }); return ServerResponse.async(responseMono); } else { - Mono responseMono = + // regular GraphQL flow + Mono responseMono = this.graphQlHandler .handleRequest(graphQlRequest) - .map( - response -> { + .doOnNext( + (response) -> { if (logger.isDebugEnabled()) { - logger.debug("Execution complete"); + List errors = response.getErrors(); + logger.debug( + "Execution result " + + (!CollectionUtils.isEmpty(errors) + ? "has errors: " + errors + : "is ready") + + "."); } - ServerResponse.BodyBuilder builder = ServerResponse.ok(); - builder.headers(headers -> headers.putAll(response.getResponseHeaders())); - builder.contentType(selectResponseMediaType(serverRequest)); - return builder.body(response.toMap()); }); + return prepareResponse(serverRequest, responseMono); + } + } - return ServerResponse.async(responseMono); + private Mono emptyResponse(WebGraphQlRequest request) { + var emptyResponse = ExecutionResult.newExecutionResult().data(null).build(); + var emptyExecutionResponse = + new DefaultExecutionGraphQlResponse(request.toExecutionInput(), emptyResponse); + var emptyGraphQLResponse = new WebGraphQlResponse(emptyExecutionResponse); + emptyGraphQLResponse + .getResponseHeaders() + .add(SUBSCRIPTION_PROTOCOL_HEADER, SUBSCRIPTION_PROTOCOL_HEADER_VALUE); + return Mono.just(emptyGraphQLResponse); + } + + // ========= CODE BELOW COPIED FROM SPRING ============= + // Those are all private static methods so sadly we cannot access them directly :( + // copy from AbstractGraphQlHttpHandler + private static MultiValueMap initCookies(ServerRequest serverRequest) { + MultiValueMap source = serverRequest.cookies(); + MultiValueMap target = new LinkedMultiValueMap<>(source.size()); + source + .values() + .forEach( + (cookieList) -> + cookieList.forEach( + (cookie) -> { + HttpCookie httpCookie = new HttpCookie(cookie.getName(), cookie.getValue()); + target.add(cookie.getName(), httpCookie); + })); + return target; + } + + // copy from AbstractGraphQlHttpHandler + private GraphQlRequest readBody(ServerRequest request) throws ServletException { + try { + if (this.messageConverter != null) { + MediaType contentType = request.headers().contentType().orElse(MediaType.APPLICATION_JSON); + if (this.messageConverter.canRead(SerializableGraphQlRequest.class, contentType)) { + ServerHttpRequest httpRequest = new ServletServerHttpRequest(request.servletRequest()); + return (GraphQlRequest) + this.messageConverter.read(SerializableGraphQlRequest.class, httpRequest); + } + throw new HttpMediaTypeNotSupportedException( + contentType, this.messageConverter.getSupportedMediaTypes(), request.method()); + } else { + try { + return request.body(SerializableGraphQlRequest.class); + } catch (HttpMediaTypeNotSupportedException ex) { + return applyApplicationGraphQlFallback(request, ex); + } + } + } catch (IOException ex) { + throw new ServerWebInputException("I/O error while reading request body", null, ex); } } - private static MediaType selectResponseMediaType(ServerRequest serverRequest) { - for (MediaType accepted : serverRequest.headers().accept()) { - if (SUPPORTED_MEDIA_TYPES.contains(accepted)) { - return accepted; + // copy from AbstractGraphQlHttpHandler + private static SerializableGraphQlRequest applyApplicationGraphQlFallback( + ServerRequest request, HttpMediaTypeNotSupportedException ex) + throws HttpMediaTypeNotSupportedException { + + // Spec requires application/json but some clients still use application/graphql + if ("application/graphql".equals(request.headers().firstHeader(HttpHeaders.CONTENT_TYPE))) { + try { + request = + ServerRequest.from(request) + .headers((headers) -> headers.setContentType(MediaType.APPLICATION_JSON)) + .body(request.body(byte[].class)) + .build(); + return request.body(SerializableGraphQlRequest.class); + } catch (Throwable ex2) { + // ignore } } - return MediaType.APPLICATION_JSON; + throw ex; } }