dgs-framework: bug: File upload doesn't work with Spring WebFlux

Expected behavior

According to DGS document and GraphQL multipart request specification, file upload should work as expected.

Actual behavior

com.netflix.graphql.dgs.webflux.handlers.DefaultDgsWebfluxHttpHandler will treat form-data requests as application/json and use com.fasterxml.jackson.databind.ObjectMapper to parse the requests. Then the framework will throw the following exception:

Error has been observed at the following site(s):
	*_______________________________________Mono.map ⇢ at com.netflix.graphql.dgs.webflux.handlers.DefaultDgsWebfluxHttpHandler.graphql(DefaultDgsWebfluxHttpHandler.kt:35)
	|_                                  Mono.flatMap ⇢ at com.netflix.graphql.dgs.webflux.handlers.DefaultDgsWebfluxHttpHandler.graphql(DefaultDgsWebfluxHttpHandler.kt:48)
	|_                                  Mono.flatMap ⇢ at com.netflix.graphql.dgs.webflux.handlers.DefaultDgsWebfluxHttpHandler.graphql(DefaultDgsWebfluxHttpHandler.kt:61)
	*___________________________________Mono.flatMap ⇢ at org.springframework.cloud.sleuth.instrument.web.TraceHandlerFunction.handle(TraceHandlerFunction.java:54)
	|_                                Mono.doFinally ⇢ at org.springframework.cloud.sleuth.instrument.web.TraceHandlerFunction.handle(TraceHandlerFunction.java:54)
	|_                                      Mono.map ⇢ at org.springframework.web.reactive.function.server.support.HandlerFunctionAdapter.handle(HandlerFunctionAdapter.java:62)
	*___________________________________Mono.flatMap ⇢ at org.springframework.web.reactive.DispatcherHandler.handle(DispatcherHandler.java:153)
	|_                                  Mono.flatMap ⇢ at org.springframework.web.reactive.DispatcherHandler.handle(DispatcherHandler.java:154)
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	*______________________________________Mono.then ⇢ at org.springframework.security.web.server.authentication.AuthenticationWebFilter.onAuthenticationSuccess(AuthenticationWebFilter.java:135)
	*___________________________________Mono.flatMap ⇢ at org.springframework.security.web.server.authentication.AuthenticationWebFilter.authenticate(AuthenticationWebFilter.java:124)
	|_                                Mono.doOnError ⇢ at org.springframework.security.web.server.authentication.AuthenticationWebFilter.authenticate(AuthenticationWebFilter.java:126)
	*___________________________________Mono.flatMap ⇢ at org.springframework.security.web.server.authentication.AuthenticationWebFilter.filter(AuthenticationWebFilter.java:114)
	|_                            Mono.onErrorResume ⇢ at org.springframework.security.web.server.authentication.AuthenticationWebFilter.filter(AuthenticationWebFilter.java:115)
	|_                                    checkpoint ⇢ com.hohomalls.web.filter.AuthenticationFilter [DefaultWebFilterChain]
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	*_____________________________Mono.switchIfEmpty ⇢ at org.springframework.security.web.server.authorization.AuthorizationWebFilter.filter(AuthorizationWebFilter.java:55)
	|_                                    checkpoint ⇢ org.springframework.security.web.server.authorization.AuthorizationWebFilter [DefaultWebFilterChain]
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	|_                            Mono.onErrorResume ⇢ at org.springframework.security.web.server.authorization.ExceptionTranslationWebFilter.filter(ExceptionTranslationWebFilter.java:58)
	|_                                    checkpoint ⇢ org.springframework.security.web.server.authorization.ExceptionTranslationWebFilter [DefaultWebFilterChain]
	*__Operators$MultiSubscriptionSubscriber.onError ⇢ at org.springframework.cloud.sleuth.instrument.reactor.ScopePassingSpanSubscriber.onError(ScopePassingSpanSubscriber.java:96)
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	*___________________________________Mono.flatMap ⇢ at org.springframework.security.web.server.savedrequest.ServerRequestCacheWebFilter.filter(ServerRequestCacheWebFilter.java:39)
	|_                                    checkpoint ⇢ org.springframework.security.web.server.savedrequest.ServerRequestCacheWebFilter [DefaultWebFilterChain]
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	|_                                    checkpoint ⇢ org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter [DefaultWebFilterChain]
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	*______________________________________Mono.then ⇢ at org.springframework.security.web.server.authentication.AuthenticationWebFilter.onAuthenticationSuccess(AuthenticationWebFilter.java:135)
	*___________________________________Mono.flatMap ⇢ at org.springframework.security.web.server.authentication.AuthenticationWebFilter.authenticate(AuthenticationWebFilter.java:124)
	|_                                Mono.doOnError ⇢ at org.springframework.security.web.server.authentication.AuthenticationWebFilter.authenticate(AuthenticationWebFilter.java:126)
	*___________________________________Mono.flatMap ⇢ at org.springframework.security.web.server.authentication.AuthenticationWebFilter.filter(AuthenticationWebFilter.java:114)
	|_                            Mono.onErrorResume ⇢ at org.springframework.security.web.server.authentication.AuthenticationWebFilter.filter(AuthenticationWebFilter.java:115)
	|_                                    checkpoint ⇢ com.hohomalls.web.filter.AuthenticationFilter [DefaultWebFilterChain]
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	|_                                    checkpoint ⇢ org.springframework.security.web.server.context.ReactorContextWebFilter [DefaultWebFilterChain]
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	|_                                    checkpoint ⇢ org.springframework.security.config.web.server.ServerHttpSecurity$ServerWebExchangeReactorContextWebFilter [DefaultWebFilterChain]
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	*___________________________________Mono.flatMap ⇢ at org.springframework.security.web.server.WebFilterChainProxy.filter(WebFilterChainProxy.java:56)
	|_                                    checkpoint ⇢ org.springframework.security.web.server.WebFilterChainProxy [DefaultWebFilterChain]
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	|_                                    checkpoint ⇢ org.springframework.cloud.sleuth.instrument.web.TraceWebFilter [DefaultWebFilterChain]
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	|_                                 Mono.doOnEach ⇢ at org.springframework.boot.actuate.metrics.web.reactive.server.MetricsWebFilter.filter(MetricsWebFilter.java:87)
	|_                               Mono.doOnCancel ⇢ at org.springframework.boot.actuate.metrics.web.reactive.server.MetricsWebFilter.filter(MetricsWebFilter.java:88)
	*_________________________Mono.transformDeferred ⇢ at org.springframework.boot.actuate.metrics.web.reactive.server.MetricsWebFilter.filter(MetricsWebFilter.java:82)
	|_                                    checkpoint ⇢ org.springframework.boot.actuate.metrics.web.reactive.server.MetricsWebFilter [DefaultWebFilterChain]
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	|_                                    checkpoint ⇢ com.hohomalls.web.filter.CorsFilter [DefaultWebFilterChain]
	*_____________________________________Mono.defer ⇢ at org.springframework.web.server.handler.DefaultWebFilterChain.filter(DefaultWebFilterChain.java:119)
	|_                            Mono.onErrorResume ⇢ at org.springframework.web.server.handler.ExceptionHandlingWebHandler.handle(ExceptionHandlingWebHandler.java:77)
	*_____________________________________Mono.error ⇢ at org.springframework.web.server.handler.ExceptionHandlingWebHandler$CheckpointInsertingHandler.handle(ExceptionHandlingWebHandler.java:98)
	|_                                    checkpoint ⇢ HTTP POST "/graphql" [ExceptionHandlingWebHandler]
Original Stack Trace:
		at com.fasterxml.jackson.core.JsonParser._constructError(JsonParser.java:2391) ~[jackson-core-2.13.0.jar:2.13.0]
		at com.fasterxml.jackson.core.base.ParserMinimalBase._reportError(ParserMinimalBase.java:735) ~[jackson-core-2.13.0.jar:2.13.0]
		at com.fasterxml.jackson.core.base.ParserMinimalBase.reportUnexpectedNumberChar(ParserMinimalBase.java:557) ~[jackson-core-2.13.0.jar:2.13.0]
		at com.fasterxml.jackson.core.json.ReaderBasedJsonParser._handleInvalidNumberStart(ReaderBasedJsonParser.java:1718) ~[jackson-core-2.13.0.jar:2.13.0]
		at com.fasterxml.jackson.core.json.ReaderBasedJsonParser._parseNegNumber(ReaderBasedJsonParser.java:1467) ~[jackson-core-2.13.0.jar:2.13.0]
		at com.fasterxml.jackson.core.json.ReaderBasedJsonParser.nextToken(ReaderBasedJsonParser.java:784) ~[jackson-core-2.13.0.jar:2.13.0]
		at com.fasterxml.jackson.databind.ObjectMapper._initForReading(ObjectMapper.java:4762) ~[jackson-databind-2.13.0.jar:2.13.0]
		at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4668) ~[jackson-databind-2.13.0.jar:2.13.0]
		at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3630) ~[jackson-databind-2.13.0.jar:2.13.0]
		at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3613) ~[jackson-databind-2.13.0.jar:2.13.0]
		at com.netflix.graphql.dgs.webflux.handlers.DefaultDgsWebfluxHttpHandler.graphql$lambda-0(DefaultDgsWebfluxHttpHandler.kt:79) ~[graphql-dgs-spring-webflux-autoconfigure-4.9.14.jar:4.9.14]
		at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onNext(FluxMapFuseable.java:113) ~[reactor-core-3.4.12.jar:3.4.12]
		at reactor.core.publisher.FluxOnErrorResume$ResumeSubscriber.onNext(FluxOnErrorResume.java:79) ~[reactor-core-3.4.12.jar:3.4.12]
		at reactor.core.publisher.FluxOnErrorResume$ResumeSubscriber.onNext(FluxOnErrorResume.java:79) ~[reactor-core-3.4.12.jar:3.4.12]
		at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onNext(FluxMapFuseable.java:127) ~[reactor-core-3.4.12.jar:3.4.12]
		at reactor.core.publisher.FluxContextWrite$ContextWriteSubscriber.onNext(FluxContextWrite.java:107) ~[reactor-core-3.4.12.jar:3.4.12]
		at reactor.core.publisher.FluxMapFuseable$MapFuseableConditionalSubscriber.onNext(FluxMapFuseable.java:295) ~[reactor-core-3.4.12.jar:3.4.12]
		at reactor.core.publisher.FluxFilterFuseable$FilterFuseableConditionalSubscriber.onNext(FluxFilterFuseable.java:337) ~[reactor-core-3.4.12.jar:3.4.12]
		at reactor.core.publisher.Operators$MonoSubscriber.complete(Operators.java:1816) ~[reactor-core-3.4.12.jar:3.4.12]
		at reactor.core.publisher.MonoCollect$CollectSubscriber.onComplete(MonoCollect.java:159) ~[reactor-core-3.4.12.jar:3.4.12]
		at org.springframework.cloud.sleuth.instrument.reactor.ScopePassingSpanSubscriber.onComplete(ScopePassingSpanSubscriber.java:103) ~[spring-cloud-sleuth-instrumentation-3.1.0.jar:3.1.0]
		at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onComplete(FluxMapFuseable.java:150) ~[reactor-core-3.4.12.jar:3.4.12]
		at org.springframework.cloud.sleuth.instrument.reactor.ScopePassingSpanSubscriber.onComplete(ScopePassingSpanSubscriber.java:103) ~[spring-cloud-sleuth-instrumentation-3.1.0.jar:3.1.0]
		at reactor.core.publisher.FluxPeekFuseable$PeekFuseableSubscriber.onComplete(FluxPeekFuseable.java:277) ~[reactor-core-3.4.12.jar:3.4.12]
		at org.springframework.cloud.sleuth.instrument.reactor.ScopePassingSpanSubscriber.onComplete(ScopePassingSpanSubscriber.java:103) ~[spring-cloud-sleuth-instrumentation-3.1.0.jar:3.1.0]
		at reactor.core.publisher.FluxMap$MapSubscriber.onComplete(FluxMap.java:142) ~[reactor-core-3.4.12.jar:3.4.12]
		at reactor.netty.channel.FluxReceive.onInboundComplete(FluxReceive.java:400) ~[reactor-netty-core-1.0.13.jar:1.0.13]
		at reactor.netty.channel.ChannelOperations.onInboundComplete(ChannelOperations.java:419) ~[reactor-netty-core-1.0.13.jar:1.0.13]
		at reactor.netty.http.server.HttpServerOperations.onInboundNext(HttpServerOperations.java:590) ~[reactor-netty-http-1.0.13.jar:1.0.13]
		at reactor.netty.channel.ChannelOperationsHandler.channelRead(ChannelOperationsHandler.java:93) ~[reactor-netty-core-1.0.13.jar:1.0.13]
		at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at reactor.netty.http.server.HttpTrafficHandler.channelRead(HttpTrafficHandler.java:264) ~[reactor-netty-http-1.0.13.jar:1.0.13]
		at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.CombinedChannelDuplexHandler$DelegatingChannelHandlerContext.fireChannelRead(CombinedChannelDuplexHandler.java:436) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:324) ~[netty-codec-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:296) ~[netty-codec-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.CombinedChannelDuplexHandler.channelRead(CombinedChannelDuplexHandler.java:251) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1410) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:919) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:166) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:719) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:655) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:581) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:493) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:986) ~[netty-common-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) ~[netty-common-4.1.70.Final.jar:4.1.70.Final]
		at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) ~[netty-common-4.1.70.Final.jar:4.1.70.Final]
		at java.base/java.lang.Thread.run(Thread.java:833) ~[na:na]

Steps to reproduce

I have used cURL, Postman and Altair GraphQL Client to test the file upload without luck.

scalar Upload

type Mutation {
    # Upload a file and return the URL on the server
    uploadFile(file: Upload!, rootDir: String!, subDir: String!): String
}
  @DgsData(parentType = "Mutation")
  public Mono<String> uploadFile(DataFetchingEnvironment env) {
    FileDataFetcher.log.info("Received a request to upload a file");
}
  • Use cURL to upload the file:
curl http://localhost:8080/graphql \
-F operations='{"query":"mutation ($file: Upload!) {\n  uploadFile(file: $file, rootDir: \"rootDir\", subDir: \"subDir\")\n}","variables":{"file":null},"operationName":null}' \
-F map='{ "0": ["variables.file"] }' \
-F 0=@movie.txt
  • Error responses in cURL, Postman and Altair GraphQL Client:
{
  "errors": [
    {
      "message": "Unexpected character ('-' (code 45)) in numeric value: expected digit (0-9) to follow minus sign, for valid numeric value\n at [Source: (String)\"------WebKitFormBoundaryq8A4KbH60pKaWgNR\r\nContent-Disposition: form-data; name=\"operations\"\r\n\r\n{\"query\":\"mutation ($file: Upload!) {\\n  uploadFile(file: $file, rootDir: \\\"rootDir\\\", subDir: \\\"subDir\\\")\\n}\",\"variables\":{\"file\":null},\"operationName\":null}\r\n------WebKitFormBoundaryq8A4KbH60pKaWgNR\r\nContent-Disposition: form-data; name=\"map\"\r\n\r\n{\"0\":[\"variables.file\"]}\r\n------WebKitFormBoundaryq8A4KbH60pKaWgNR\r\nContent-Disposition: form-data; name=\"0\"; filename=\"movie.txt\"\r\nContent-Type: text/plain\r\"[truncated 60 chars]; line: 1, column: 3]",
      "path": null,
      "locations": [
        "/graphql"
      ],
      "extensions": {
        "errorType": "INTERNAL",
        "origin": "APP",
        "debugInfo": {
          "exceptionId": "625c0be7-4a5f-4a92-b2fc-6f8d45447ad9",
          "exceptionName": "JsonParseException"
        }
      }
    }
  ],
  "status": 500
}

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 3
  • Comments: 15 (6 by maintainers)

Most upvoted comments

I must admit that DGS library is well designed and I have never had any problems using it in WebFlux mode. That is, until I had to implement file uploads. The lack of file uploads in WebFlux is not documented, so I was very disappointed when I found this issue open.

I need file uploads, so I came up with workaround. This is essentially custom HTTP handler bean with few classes operating on Part class instead of MultipartFile. I’m not familiar with Kotlin unfortunately, so I can’t make a PR at the moment. For those who may find it handy I’m leaving a link to my Java hack.

It would be great if maintainers implemented file uploads in WebFlux flavor!

Pretty, pretty please with sugar on top 😉

Awesome. Meanwhile if anyone is looking for a Kotlin version of the hack initially provided by @bartebor You’ll find it here: https://gist.github.com/lthoulon-locala/02efaf339d9f6b8795bc48f425926efe

I reworked this based on the original Netflix files so that the code is as close as possible to theirs.

As a side effect of some bigger news coming soon, this will actually be supported soon.