Skip to content

Commit b3fbc1c

Browse files
ajozwikadamwclaude
authored
Implement multipart for netty-future, netty-cats & netty-zio (#5315)
Proposal of multipart implementation for netty - see #4851 - only multipart. Implementation based on `nettyServerSync` implementation - but without OxStreams. According to [Note: Netty's multipart decoder does not expose other part headers, nor other disposition params.](https://github.com/softwaremill/tapir/blob/a4343ea90db20bec3cdad835c0c8e119ef788120/server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyRequestBody.scala#L135) the test with netty should ignoring part headers - `partOtherHeaderSupport = false` Issue for support headers has been requested: [FileUpload or HttpData should contain HTTP-Headers ](netty/netty#15445) What is missing? - Part headers if netty api expose it - Configuration for multipart temp directory and minimum disk size - Streaming - but it is not part of multipart. For sync implementation multipart depends on OxStreams - what is not needed and can be moved to other submodule. --------- Co-authored-by: Adam Warski <adam.warski@softwaremill.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0254196 commit b3fbc1c

24 files changed

Lines changed: 420 additions & 274 deletions

File tree

build.sbt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ val commonSettings = commonSmlBuildSettings ++ ossPublishSettings ++ Seq(
8787
case _ => Seq("-Xmax-inlines", "64")
8888
}
8989
},
90-
Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.Assertion:s",
91-
Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.compatible.Assertion:s",
90+
scalacOptions += "-Wconf:msg=unused value of type org.scalatest.Assertion:s",
91+
scalacOptions += "-Wconf:msg=unused value of type org.scalatest.compatible.Assertion:s",
9292
evictionErrorLevel := Level.Info
9393
)
9494

observability/zio-opentelemetry/src/test/scala/sttp/tapir/server/ziopentelemetry/ZIOpenTelemetryTracingSpec.scala

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ object ZIOpenTelemetryTracingSpec extends ZIOSpecDefault {
131131
// draining mimics the backend sending the body; tolerate failures so the body-listener callback (which
132132
// marks the span as errored) runs and the finished span can be inspected
133133
ZIO.logDebug("streaming") *>
134-
stream.runDrain.catchAllCause(_ => ZIO.unit)
134+
stream.runDrain.catchAllCause(_ => ZIO.unit)
135135
case other =>
136136
ZIO.logDebug(s"WHat is it: $other ?")
137137
}
@@ -727,7 +727,9 @@ object ZIOpenTelemetryTracingSpec extends ZIOSpecDefault {
727727
val ep: ServerEndpoint[ZioStreams, Task] = endpoint
728728
.in("stream")
729729
.out(streamTextBody(ZioStreams)(CodecFormat.TextPlain(), Some(StandardCharsets.UTF_8)))
730-
.zServerLogic(_ => ZIO.succeed(ZStream.fromIterable("abc".getBytes(StandardCharsets.UTF_8)).flatMap(_ => ZStream.fail(new RuntimeException("boom")))))
730+
.zServerLogic(_ =>
731+
ZIO.succeed(ZStream.fromIterable("abc".getBytes(StandardCharsets.UTF_8)).flatMap(_ => ZStream.fail(new RuntimeException("boom"))))
732+
)
731733

732734
val request = serverRequestFromUri(uri"http://example.com/stream")
733735
for {

server/netty-server/cats/src/main/scala/sttp/tapir/server/netty/cats/NettyCatsServerInterpreter.scala

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ trait NettyCatsServerInterpreter[F[_]] {
3333

3434
val serverInterpreter = new ServerInterpreter[Fs2Streams[F] with WebSockets, F, NettyResponse, Fs2Streams[F]](
3535
FilterServerEndpoints(ses),
36-
new NettyCatsRequestBody(createFile, Fs2StreamCompatible[F](nettyServerOptions.dispatcher)),
36+
new NettyCatsRequestBody(
37+
createFile,
38+
Fs2StreamCompatible[F](nettyServerOptions.dispatcher),
39+
nettyServerOptions.multipartTempDirectory,
40+
nettyServerOptions.multipartMinSizeForDisk
41+
),
3742
new NettyToStreamsResponseBody(Fs2StreamCompatible[F](nettyServerOptions.dispatcher)),
3843
interceptors,
3944
deleteFile

server/netty-server/cats/src/main/scala/sttp/tapir/server/netty/cats/NettyCatsServerOptions.scala

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ case class NettyCatsServerOptions[F[_]](
1818
interceptors: List[Interceptor[F]],
1919
createFile: ServerRequest => F[TapirFile],
2020
deleteFile: TapirFile => F[Unit],
21-
dispatcher: Dispatcher[F]
21+
dispatcher: Dispatcher[F],
22+
multipartTempDirectory: Option[TapirFile],
23+
multipartMinSizeForDisk: Option[Long]
2224
) {
2325
def prependInterceptor(i: Interceptor[F]): NettyCatsServerOptions[F] = copy(interceptors = i :: interceptors)
2426
def appendInterceptor(i: Interceptor[F]): NettyCatsServerOptions[F] = copy(interceptors = interceptors :+ i)
@@ -37,7 +39,9 @@ object NettyCatsServerOptions {
3739
interceptors,
3840
_ => Sync[F].delay(Defaults.createTempFile()),
3941
file => Sync[F].delay(Defaults.deleteFile()(file)),
40-
dispatcher
42+
dispatcher,
43+
None,
44+
None
4145
)
4246

4347
def customiseInterceptors[F[_]: Async](
Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,37 @@
11
package sttp.tapir.server.netty.cats.internal
22

33
import cats.effect.Async
4-
import cats.syntax.all._
54
import fs2.Chunk
65
import fs2.io.file.{Files, Path}
76
import io.netty.handler.codec.http.HttpContent
8-
import org.playframework.netty.http.StreamedHttpRequest
97
import org.reactivestreams.Publisher
108
import sttp.capabilities.fs2.Fs2Streams
119
import sttp.monad.MonadError
1210
import sttp.tapir.integ.cats.effect.CatsMonadError
1311
import sttp.tapir.model.ServerRequest
14-
import sttp.tapir.server.interpreter.RawValue
1512
import sttp.tapir.server.netty.internal.{NettyStreamingRequestBody, StreamCompatible}
16-
import sttp.tapir.{RawBodyType, RawPart, TapirFile}
13+
import sttp.tapir.{RawPart, TapirFile}
14+
15+
import scala.concurrent.Future
1716

1817
private[cats] class NettyCatsRequestBody[F[_]: Async](
1918
val createFile: ServerRequest => F[TapirFile],
20-
val streamCompatible: StreamCompatible[Fs2Streams[F]]
19+
val streamCompatible: StreamCompatible[Fs2Streams[F]],
20+
val multipartTempDirectory: Option[TapirFile],
21+
val multipartMinSizeForDisk: Option[Long]
2122
) extends NettyStreamingRequestBody[F, Fs2Streams[F]] {
2223

2324
override implicit val monad: MonadError[F] = new CatsMonadError()
2425

26+
import cats.implicits._
27+
28+
override protected def listMonadToMonadOfList(l: List[F[RawPart]]): F[List[RawPart]] = l.sequence
29+
30+
override protected def fromFuture[T](f: Future[T]): F[T] = Async[F].fromFuture(Async[F].delay(f))
31+
2532
override def publisherToBytes(publisher: Publisher[HttpContent], contentLength: Option[Long], maxBytes: Option[Long]): F[Array[Byte]] =
2633
streamCompatible.fromPublisher(publisher, maxBytes).compile.to(Chunk).map(_.toArray[Byte])
2734

28-
def publisherToMultipart(
29-
nettyRequest: StreamedHttpRequest,
30-
serverRequest: ServerRequest,
31-
m: RawBodyType.MultipartBody,
32-
maxBytes: Option[Long]
33-
): F[RawValue[Seq[RawPart]]] = monad.error(new UnsupportedOperationException("Multipart requests are not supported"))
34-
3535
override def writeToFile(serverRequest: ServerRequest, file: TapirFile, maxBytes: Option[Long]): F[Unit] =
3636
(toStream(serverRequest, maxBytes)
3737
.asInstanceOf[streamCompatible.streams.BinaryStream])
@@ -41,6 +41,4 @@ private[cats] class NettyCatsRequestBody[F[_]: Async](
4141
.compile
4242
.drain
4343

44-
override def writeBytesToFile(bytes: Array[Byte], file: TapirFile): F[Unit] =
45-
monad.error(new UnsupportedOperationException("Multipart requests are not supported"))
4644
}

server/netty-server/cats/src/test/scala/sttp/tapir/server/netty/cats/NettyCatsServerTest.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class NettyCatsServerTest extends TestSuite with EitherValues {
3434
createServerTest,
3535
interpreter,
3636
backend,
37-
multipart = false
37+
partOtherHeaderSupport = false
3838
)
3939
.tests() ++
4040
new ServerStreamingTests(createServerTest).tests(Fs2Streams[IO])(drainFs2) ++

server/netty-server/src/main/scala/sttp/tapir/server/netty/NettyFutureServerInterpreter.scala

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ trait NettyFutureServerInterpreter {
2020
NettyServerInterpreter.toRoute(
2121
ses,
2222
nettyServerOptions.interceptors,
23-
new NettyFutureRequestBody(nettyServerOptions.createFile),
23+
new NettyFutureRequestBody(
24+
nettyServerOptions.createFile,
25+
nettyServerOptions.multipartTempDirectory,
26+
nettyServerOptions.multipartMinSizeForDisk
27+
),
2428
new NettyToResponseBody[Future](RunAsync.Future),
2529
nettyServerOptions.deleteFile,
2630
RunAsync.Future

server/netty-server/src/main/scala/sttp/tapir/server/netty/NettyFutureServerOptions.scala

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import scala.concurrent.{Future, blocking}
1717
case class NettyFutureServerOptions(
1818
interceptors: List[Interceptor[Future]],
1919
createFile: ServerRequest => Future[TapirFile],
20-
deleteFile: TapirFile => Future[Unit]
20+
deleteFile: TapirFile => Future[Unit],
21+
multipartTempDirectory: Option[TapirFile],
22+
multipartMinSizeForDisk: Option[Long]
2123
) {
2224
def prependInterceptor(i: Interceptor[Future]): NettyFutureServerOptions = copy(interceptors = i :: interceptors)
2325
def appendInterceptor(i: Interceptor[Future]): NettyFutureServerOptions = copy(interceptors = interceptors :+ i)
@@ -37,7 +39,9 @@ object NettyFutureServerOptions {
3739
file => {
3840
import scala.concurrent.ExecutionContext.Implicits.global
3941
Future(blocking(Defaults.deleteFile()(file)))
40-
}
42+
},
43+
None,
44+
None
4145
)
4246

4347
/** Customise the interceptors that are being used when exposing endpoints as a server. */

server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyFutureRequestBody.scala

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,41 +7,38 @@ import sttp.capabilities
77
import sttp.monad.{FutureMonad, MonadError}
88
import sttp.tapir.capabilities.NoStreams
99
import sttp.tapir.model.ServerRequest
10-
import sttp.tapir.server.interpreter.RawValue
1110
import sttp.tapir.server.netty.internal.reactivestreams._
12-
import sttp.tapir.{RawBodyType, RawPart, TapirFile}
11+
import sttp.tapir.{RawPart, TapirFile}
1312

1413
import scala.concurrent.{ExecutionContext, Future}
1514

16-
private[netty] class NettyFutureRequestBody(val createFile: ServerRequest => Future[TapirFile])(implicit ec: ExecutionContext)
17-
extends NettyRequestBody[Future, NoStreams] {
15+
private[netty] class NettyFutureRequestBody(
16+
val createFile: ServerRequest => Future[TapirFile],
17+
val multipartTempDirectory: Option[TapirFile],
18+
val multipartMinSizeForDisk: Option[Long]
19+
)(implicit ec: ExecutionContext)
20+
extends NettyMonadRequestBody[Future, NoStreams] {
1821

1922
override val streams: capabilities.Streams[NoStreams] = NoStreams
2023
override implicit val monad: MonadError[Future] = new FutureMonad()
2124

25+
protected def listMonadToMonadOfList(l: List[Future[RawPart]]): Future[List[RawPart]] = Future.sequence(l)
26+
27+
override protected def fromFuture[T](f: Future[T]): Future[T] = f
28+
2229
override def publisherToBytes(
2330
publisher: Publisher[HttpContent],
2431
contentLength: Option[Long],
2532
maxBytes: Option[Long]
2633
): Future[Array[Byte]] =
2734
SimpleSubscriber.processAll(publisher, contentLength, maxBytes)
2835

29-
override def publisherToMultipart(
30-
nettyRequest: StreamedHttpRequest,
31-
serverRequest: ServerRequest,
32-
m: RawBodyType.MultipartBody,
33-
maxBytes: Option[Long]
34-
): Future[RawValue[Seq[RawPart]]] = Future.failed(new UnsupportedOperationException("Multipart requests are not supported"))
35-
3636
override def writeToFile(serverRequest: ServerRequest, file: TapirFile, maxBytes: Option[Long]): Future[Unit] =
3737
serverRequest.underlying match {
3838
case r: StreamedHttpRequest => FileWriterSubscriber.processAll(r, file.toPath, maxBytes)
3939
case _ => monad.unit(()) // Empty request
4040
}
4141

42-
override def writeBytesToFile(bytes: Array[Byte], file: TapirFile): Future[Unit] =
43-
Future.failed(new UnsupportedOperationException("Multipart requests are not supported"))
44-
4542
override def toStream(serverRequest: ServerRequest, maxBytes: Option[Long]): streams.BinaryStream =
4643
throw new UnsupportedOperationException()
4744
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package sttp.tapir.server.netty.internal
2+
3+
import io.netty.handler.codec.http.HttpContent
4+
import io.netty.handler.codec.http.multipart.{HttpPostMultipartRequestDecoder, InterfaceHttpData}
5+
import org.playframework.netty.http.StreamedHttpRequest
6+
import org.reactivestreams.{Subscriber, Subscription}
7+
import sttp.capabilities.{StreamMaxLengthExceededException, Streams}
8+
import sttp.monad.syntax._
9+
import sttp.tapir.model.ServerRequest
10+
import sttp.tapir.server.interpreter.RawValue
11+
import sttp.tapir.{RawBodyType, RawPart, TapirFile}
12+
13+
import java.nio.channels.{AsynchronousFileChannel, CompletionHandler}
14+
import java.nio.file.StandardOpenOption
15+
import java.nio.ByteBuffer
16+
import java.util.concurrent.atomic.{AtomicLong, AtomicReference}
17+
import scala.concurrent.{Future, Promise}
18+
19+
/** Generic implementation of NettyRequestBody, for any effect F[_] and any stream type S. */
20+
private[netty] trait NettyMonadRequestBody[F[_], S <: Streams[S]] extends NettyRequestBody[F, S] {
21+
22+
protected def listMonadToMonadOfList(l: List[F[RawPart]]): F[List[RawPart]]
23+
protected def fromFuture[T](f: Future[T]): F[T]
24+
25+
override def publisherToMultipart(
26+
nettyRequest: StreamedHttpRequest,
27+
serverRequest: ServerRequest,
28+
m: RawBodyType.MultipartBody,
29+
maxBytes: Option[Long]
30+
): F[RawValue[Seq[RawPart]]] = {
31+
32+
val promise = Promise[F[RawValue[Seq[RawPart]]]]()
33+
34+
val decoder = new HttpPostMultipartRequestDecoder(httpDataFactory, nettyRequest)
35+
val subscriber = new MonadSubscriber(decoder, serverRequest, m, maxBytes, listMonadToMonadOfList)(promise)
36+
nettyRequest.subscribe(subscriber)
37+
val future = promise.future
38+
val ff = fromFuture(future)
39+
monad.flatten(ff)
40+
41+
}
42+
43+
private class MonadSubscriber(
44+
decoder: HttpPostMultipartRequestDecoder,
45+
serverRequest: ServerRequest,
46+
m: RawBodyType.MultipartBody,
47+
maxBytes: Option[Long],
48+
seqMonadToMonadOfSeq: List[F[RawPart]] => F[List[RawPart]]
49+
)(
50+
promise: Promise[F[RawValue[Seq[RawPart]]]]
51+
) extends Subscriber[HttpContent] {
52+
private val currentBytesRead: AtomicLong = new AtomicLong()
53+
private val acc = new AtomicReference[List[F[RawPart]]](Nil)
54+
private val subscription = new AtomicReference[Subscription]()
55+
56+
override def onSubscribe(s: Subscription): Unit = {
57+
subscription.set(s)
58+
subscription.get().request(1)
59+
}
60+
61+
override def onNext(httpContent: HttpContent): Unit = {
62+
val currentRead = currentBytesRead.accumulateAndGet(httpContent.content().readableBytes().toLong, (prev, toAdd) => prev + toAdd)
63+
maxBytes match {
64+
case Some(max) if currentRead > max =>
65+
httpContent.release()
66+
onError(StreamMaxLengthExceededException(max))
67+
case _ =>
68+
if (addContentSafe(httpContent)) {
69+
val _ = actionOrOnError(
70+
{
71+
val parts = Iterator
72+
.continually(maybeNext())
73+
.takeWhile(_.nonEmpty)
74+
.flatten
75+
.flatMap(toPart)
76+
.toList
77+
acc.getAndAccumulate(parts, (prev, toAdd) => prev ++ toAdd)
78+
subscription.get().request(1)
79+
},
80+
()
81+
)
82+
}
83+
}
84+
}
85+
86+
override def onError(t: Throwable): Unit = {
87+
subscription.get().cancel()
88+
val _ = promise.tryFailure(t)
89+
decoder.destroy()
90+
}
91+
92+
override def onComplete(): Unit = {
93+
val f = seqMonadToMonadOfSeq(acc.get())
94+
val r = f.map(p => RawValue.fromParts(p).copy(cleanup = Option(() => decoder.destroy())))
95+
val _ = promise.trySuccess(r)
96+
}
97+
98+
private def addContentSafe(httpContent: HttpContent): Boolean =
99+
actionOrOnError(decoder.offer(httpContent), httpContent.release(): Unit)
100+
101+
private def actionOrOnError[T](action: => T, `finally`: => Unit): Boolean =
102+
try {
103+
val _ = action
104+
true
105+
} catch {
106+
case r: RuntimeException =>
107+
onError(r)
108+
false
109+
} finally {
110+
`finally`
111+
}
112+
113+
private def toPart(httpData: InterfaceHttpData): Option[F[RawPart]] =
114+
m.partType(httpData.getName).map(partType => toRawPart(serverRequest, httpData, partType).map(identity))
115+
116+
private def maybeNext(): Option[InterfaceHttpData] = if (decoder.hasNext) Option(decoder.next()) else None
117+
}
118+
119+
override def writeBytesToFile(bytes: Array[Byte], file: TapirFile): F[Unit] =
120+
fromFuture(writeBytesToFileFuture(bytes, file))
121+
122+
private def writeBytesToFileFuture(bytes: Array[Byte], file: TapirFile): Future[Unit] = {
123+
val promise = Promise[Unit]()
124+
val channel = AsynchronousFileChannel.open(file.toPath, StandardOpenOption.WRITE, StandardOpenOption.CREATE)
125+
channel.write(
126+
ByteBuffer.wrap(bytes),
127+
0,
128+
channel,
129+
new CompletionHandler[Integer, AutoCloseable]() {
130+
override def completed(result: Integer, closeable: AutoCloseable): Unit = {
131+
promise.success(())
132+
closeable.close()
133+
}
134+
135+
override def failed(exc: Throwable, closeable: AutoCloseable): Unit = {
136+
promise.failure(exc)
137+
closeable.close()
138+
}
139+
}
140+
)
141+
promise.future
142+
}
143+
144+
}

0 commit comments

Comments
 (0)