From fdfde8e73a3bca1e875a1ae96bb9519d68cd8ebf Mon Sep 17 00:00:00 2001 From: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> Date: Tue, 30 Jul 2024 21:22:54 +0200 Subject: [PATCH 1/6] Encode SSE based on HttpContentCodec (#2695) (#2951) --- .../main/scala/zio/http/ServerSentEvent.scala | 23 ++++++++++++- .../http/codec/internal/EncoderDecoder.scala | 34 ++++--------------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/zio-http/shared/src/main/scala/zio/http/ServerSentEvent.scala b/zio-http/shared/src/main/scala/zio/http/ServerSentEvent.scala index 743926a81..cf1661d78 100644 --- a/zio-http/shared/src/main/scala/zio/http/ServerSentEvent.scala +++ b/zio-http/shared/src/main/scala/zio/http/ServerSentEvent.scala @@ -16,10 +16,15 @@ package zio.http -import zio.stacktracer.TracingImplicits.disableAutoTrace +import zio._ +import zio.stream.ZPipeline + +import zio.schema.codec.{BinaryCodec, DecodeError} import zio.schema.{DeriveSchema, Schema} +import zio.http.codec.{BinaryCodecWithSchema, HttpContentCodec} + /** * Server-Sent Event (SSE) as defined by * https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events @@ -61,5 +66,21 @@ final case class ServerSentEvent( object ServerSentEvent { implicit lazy val schema: Schema[ServerSentEvent] = DeriveSchema.gen[ServerSentEvent] + implicit val contentCodec: HttpContentCodec[ServerSentEvent] = HttpContentCodec.from( + MediaType.text.`event-stream` -> BinaryCodecWithSchema.fromBinaryCodec(new BinaryCodec[ServerSentEvent] { + override def decode(whole: Chunk[Byte]): Either[DecodeError, ServerSentEvent] = + throw new UnsupportedOperationException("ServerSentEvent decoding is not yet supported.") + + override def streamDecoder: ZPipeline[Any, DecodeError, Byte, ServerSentEvent] = + throw new UnsupportedOperationException("ServerSentEvent decoding is not yet supported.") + + override def encode(value: ServerSentEvent): Chunk[Byte] = + Chunk.fromArray(value.encode.getBytes) + + override def streamEncoder: ZPipeline[Any, Nothing, ServerSentEvent, Byte] = + ZPipeline.mapChunks(value => value.flatMap(c => c.encode.getBytes)) + }), + ) + def heartbeat: ServerSentEvent = new ServerSentEvent("") } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala b/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala index b4ddc3e2e..7663d2461 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala @@ -216,11 +216,6 @@ private[codec] object EncoderDecoder { } else { false } - private val isEventStream = if (flattened.content.length == 1) { - isEventStreamBody(flattened.content(0)) - } else { - false - } private val onlyTheLastFieldIsStreaming = if (flattened.content.size > 1) { !flattened.content.init.exists(isByteStreamBody) && isByteStreamBody(flattened.content.last) @@ -533,26 +528,20 @@ private[codec] object EncoderDecoder { case SimpleCodec.Specified(method) => Some(method) } } else None - private def encodeBody(inputs: Array[Any], outputTypes: Chunk[MediaTypeWithQFactor]): Body = { + private def encodeBody(inputs: Array[Any], outputTypes: Chunk[MediaTypeWithQFactor]): Body = if (isByteStream) { Body.fromStreamChunked(inputs(0).asInstanceOf[ZStream[Any, Nothing, Byte]]) } else { - if (inputs.length > 1) { - Body.fromMultipartForm(encodeMultipartFormData(inputs, outputTypes), formBoundary) - } else { - if (isEventStream) { - Body.fromCharSequenceStreamChunked( - inputs(0).asInstanceOf[ZStream[Any, Nothing, ServerSentEvent]].map(_.encode), - ) - } else if (inputs.length < 1) { + inputs.length match { + case 0 => Body.empty - } else { + case 1 => val bodyCodec = flattened.content(0) bodyCodec.erase.encodeToBody(inputs(0), outputTypes) - } + case _ => + Body.fromMultipartForm(encodeMultipartFormData(inputs, outputTypes), formBoundary) } } - } private def encodeMultipartFormData(inputs: Array[Any], outputTypes: Chunk[MediaTypeWithQFactor]): Form = { Form( @@ -581,8 +570,7 @@ private[codec] object EncoderDecoder { if (inputs.length > 1) { Headers(Header.ContentType(MediaType.multipart.`form-data`)) } else { - if (isEventStream) Headers(Header.ContentType(MediaType.text.`event-stream`)) - else if (flattened.content.length < 1) Headers.empty + if (flattened.content.length < 1) Headers.empty else { val mediaType = flattened .content(0) @@ -599,14 +587,6 @@ private[codec] object EncoderDecoder { case BodyCodec.Multiple(codec, _) if codec.defaultMediaType.binary => true case _ => false } - - private def isEventStreamBody(codec: BodyCodec[_]): Boolean = - codec match { - case BodyCodec.Multiple(codec, _) - if codec.lookup(MediaType.text.`event-stream`).exists(_.schema == Schema[ServerSentEvent]) => - true - case _ => false - } } } From 7ca2ae59d73c546c5475d2da4718a6a3526c85d9 Mon Sep 17 00:00:00 2001 From: Jules Ivanic Date: Wed, 31 Jul 2024 17:56:40 +1000 Subject: [PATCH 2/6] Fix doc typo (#2989) --- docs/guides/testing-http-apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/testing-http-apps.md b/docs/guides/testing-http-apps.md index f5ff813ea..ac22b96f8 100644 --- a/docs/guides/testing-http-apps.md +++ b/docs/guides/testing-http-apps.md @@ -87,7 +87,7 @@ Using the `TestServer` we can write tests for our HTTP applications by starting Using the following methods we can define the behavior of the `TestServer`: - `TestServer.addRequestResponse` - Adds an exact 1-1 behavior. It takes a request and a response and returns a `ZIO[TestServer, Nothing, Unit]`. -- `TestServer.addRoute` and `TestServer.addRouts` - Adds a route definition to handle requests that are submitted by test cases. It takes a `Route` or `Routes` and returns a `ZIO[R with TestServer, Nothing, Unit]`. +- `TestServer.addRoute` and `TestServer.addRoutes` - Adds a route definition to handle requests that are submitted by test cases. It takes a `Route` or `Routes` and returns a `ZIO[R with TestServer, Nothing, Unit]`. - `TestServer.install` - Installs a `HttpApp` to the `TestServer`. After defining the behavior of the test server, we can use the `TestServer.layer` to provide the `TestServer` to any test cases that require `Server`: From 1ba77f90282e0ec4ed982b332a1aa66f4c576715 Mon Sep 17 00:00:00 2001 From: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> Date: Wed, 31 Jul 2024 09:58:18 +0200 Subject: [PATCH 3/6] Allow nesting `Routes` with unhandled errors (#2934) (#2986) Signed-off-by: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> --- zio-http/jvm/src/test/scala/zio/http/RoutesSpec.scala | 4 ++-- zio-http/shared/src/main/scala/zio/http/Route.scala | 2 +- zio-http/shared/src/main/scala/zio/http/Routes.scala | 2 +- zio-http/shared/src/main/scala/zio/http/codec/PathCodec.scala | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/zio-http/jvm/src/test/scala/zio/http/RoutesSpec.scala b/zio-http/jvm/src/test/scala/zio/http/RoutesSpec.scala index 30cc386bf..77f4b45b8 100644 --- a/zio-http/jvm/src/test/scala/zio/http/RoutesSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/RoutesSpec.scala @@ -80,10 +80,10 @@ object RoutesSpec extends ZIOHttpSpec { val routes = literal("to") / Routes( Method.GET / "other" -> handler(ZIO.fail(IdFormatError)), Method.GET / "do" / string("id") -> handler { (id: String, _: Request) => Response.text(s"GET /to/do/${id}") }, - ).handleError { case IdFormatError => + ) + routes.handleError { case IdFormatError => Response.badRequest } - routes .run( path = Path.root / "to" / "do" / "123", ) diff --git a/zio-http/shared/src/main/scala/zio/http/Route.scala b/zio-http/shared/src/main/scala/zio/http/Route.scala index 6d60cbe02..a06e612a3 100644 --- a/zio-http/shared/src/main/scala/zio/http/Route.scala +++ b/zio-http/shared/src/main/scala/zio/http/Route.scala @@ -273,7 +273,7 @@ sealed trait Route[-Env, +Err] { self => */ def location: Trace - def nest(prefix: PathCodec[Unit])(implicit ev: Err <:< Response): Route[Env, Err] = + def nest(prefix: PathCodec[Unit]): Route[Env, Err] = self match { case Provided(route, env) => Provided(route.nest(prefix), env) case Augmented(route, aspect) => Augmented(route.nest(prefix), aspect) diff --git a/zio-http/shared/src/main/scala/zio/http/Routes.scala b/zio-http/shared/src/main/scala/zio/http/Routes.scala index 6a3439bb4..fc62e18b3 100644 --- a/zio-http/shared/src/main/scala/zio/http/Routes.scala +++ b/zio-http/shared/src/main/scala/zio/http/Routes.scala @@ -117,7 +117,7 @@ final case class Routes[-Env, +Err](routes: Chunk[zio.http.Route[Env, Err]]) { s def mapError[Err1](fxn: Err => Err1): Routes[Env, Err1] = new Routes(routes.map(_.mapError(fxn))) - def nest(prefix: PathCodec[Unit])(implicit trace: Trace, ev: Err <:< Response): Routes[Env, Err] = + def nest(prefix: PathCodec[Unit])(implicit trace: Trace): Routes[Env, Err] = new Routes(self.routes.map(_.nest(prefix))) /** diff --git a/zio-http/shared/src/main/scala/zio/http/codec/PathCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/PathCodec.scala index 9df735cec..21074c138 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/PathCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/PathCodec.scala @@ -51,9 +51,9 @@ sealed trait PathCodec[A] { self => final def /[B](that: PathCodec[B])(implicit combiner: Combiner[A, B]): PathCodec[combiner.Out] = self ++ that - final def /[Env](routes: Routes[Env, Response])(implicit + final def /[Env, Err](routes: Routes[Env, Err])(implicit ev: PathCodec[A] <:< PathCodec[Unit], - ): Routes[Env, Response] = + ): Routes[Env, Err] = routes.nest(ev(self)) final def annotate(metaData: MetaData[A]): PathCodec[A] = { From 9e42c090e3c7173906f55c44f3d6e8c518814e32 Mon Sep 17 00:00:00 2001 From: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> Date: Wed, 31 Jul 2024 09:58:45 +0200 Subject: [PATCH 4/6] Handle content new lines in `fromFormAST` (#2976) (#2983) * Handle content new lines in `fromFormAST` (#2976) Signed-off-by: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> * Update zio-http/shared/src/main/scala/zio/http/FormField.scala Co-authored-by: Sergey Rublev --------- Signed-off-by: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> Co-authored-by: Sergey Rublev --- .../jvm/src/test/scala/zio/http/FormSpec.scala | 17 +++++++++++++++++ .../src/main/scala/zio/http/FormField.scala | 4 +++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/zio-http/jvm/src/test/scala/zio/http/FormSpec.scala b/zio-http/jvm/src/test/scala/zio/http/FormSpec.scala index 65897ad0d..237594bef 100644 --- a/zio-http/jvm/src/test/scala/zio/http/FormSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/FormSpec.scala @@ -386,6 +386,23 @@ object FormSpec extends ZIOHttpSpec { .runCollect .map { c => assertTrue(c == expected) } }, + test("crlf not stripped") { + val content = "1,2\r\n3,4\r\n" + val form = Form( + Chunk( + FormField.textField("csv", content, MediaType.text.`csv`), + ), + ) + val boundary = Boundary("X-INSOMNIA-BOUNDARY") + val formByteStream = form.multipartBytes(boundary) + val streamingForm = StreamingForm(formByteStream, boundary) + streamingForm.fields.mapZIO { field => + field.asChunk + }.runCollect.map { chunks => + val s = chunks.headOption.map(_.asString) + assertTrue(s.contains(content)) + } + }, ) @@ sequential def spec = diff --git a/zio-http/shared/src/main/scala/zio/http/FormField.scala b/zio-http/shared/src/main/scala/zio/http/FormField.scala index 6e45efc7d..d41cadea4 100644 --- a/zio-http/shared/src/main/scala/zio/http/FormField.scala +++ b/zio-http/shared/src/main/scala/zio/http/FormField.scala @@ -149,7 +149,7 @@ object FormField { defaultCharset: Charset = StandardCharsets.UTF_8, ): Either[FormDecodingError, FormField] = { val extract = - ast.foldLeft( + ast.init.foldLeft( ( Option.empty[FormAST.Header], Option.empty[FormAST.Header], @@ -159,6 +159,8 @@ object FormField { ) { case (accum, header: FormAST.Header) if header.name.equalsIgnoreCase("Content-Disposition") => (Some(header), accum._2, accum._3, accum._4) + case (accum, FormAST.EoL) => + (accum._1, accum._2, accum._3, accum._4 :+ FormAST.Content(FormAST.EoL.bytes)) case (accum, content: FormAST.Content) => (accum._1, accum._2, accum._3, accum._4 :+ content) case (accum, header: FormAST.Header) if header.name.equalsIgnoreCase("Content-Type") => From 5c9b5b2f7bec2c5d45ba13de59ec6ce318efbaa4 Mon Sep 17 00:00:00 2001 From: eyal farago Date: Wed, 31 Jul 2024 11:36:46 +0300 Subject: [PATCH 5/6] Body content type (#2969) --- .../scala/zio/http/internal/FetchBody.scala | 16 +-- .../scala/zio/http/internal/FetchDriver.scala | 14 ++- .../main/scala/zio/http/netty/NettyBody.scala | 35 ++---- .../zio/http/netty/NettyBodyWriter.scala | 16 +-- .../scala/zio/http/netty/NettyResponse.scala | 4 +- .../netty/server/ServerInboundHandler.scala | 2 +- .../jvm/src/test/scala/zio/http/untitled.sc | 15 +++ .../shared/src/main/scala/zio/http/Body.scala | 105 ++++++++++-------- 8 files changed, 109 insertions(+), 98 deletions(-) create mode 100644 zio-http/jvm/src/test/scala/zio/http/untitled.sc diff --git a/zio-http/js/src/main/scala/zio/http/internal/FetchBody.scala b/zio-http/js/src/main/scala/zio/http/internal/FetchBody.scala index d85082a8c..1d76962ea 100644 --- a/zio-http/js/src/main/scala/zio/http/internal/FetchBody.scala +++ b/zio-http/js/src/main/scala/zio/http/internal/FetchBody.scala @@ -12,8 +12,7 @@ import org.scalajs.dom.ReadableStream case class FetchBody( content: ReadableStream[Uint8Array], - mediaType: Option[MediaType], - private[zio] val boundary: Option[Boundary], + contentType: Option[Body.ContentType], ) extends Body { /** @@ -65,19 +64,12 @@ case class FetchBody( * Updates the media type attached to this body, returning a new Body with the * updated media type */ - override def contentType(newMediaType: MediaType): Body = - copy(mediaType = Some(newMediaType)) - - override def contentType(newMediaType: MediaType, newBoundary: Boundary): Body = - copy(mediaType = Some(newMediaType), boundary = Some(newBoundary)) + override def contentType(newContentType: Body.ContentType): Body = copy(contentType = Some(newContentType)) } object FetchBody { - def fromResponse(result: org.scalajs.dom.Response): Body = { - val mediaType = - if (result.headers.has("Content-Type")) MediaType.forContentType(result.headers.get("Content-Type")) - else None - FetchBody(result.body, mediaType, None) + def fromResponse(result: org.scalajs.dom.Response, contentType: Option[Body.ContentType]): Body = { + FetchBody(result.body, contentType) } } diff --git a/zio-http/js/src/main/scala/zio/http/internal/FetchDriver.scala b/zio-http/js/src/main/scala/zio/http/internal/FetchDriver.scala index ced813384..ec5e54279 100644 --- a/zio-http/js/src/main/scala/zio/http/internal/FetchDriver.scala +++ b/zio-http/js/src/main/scala/zio/http/internal/FetchDriver.scala @@ -38,11 +38,15 @@ final case class FetchDriver() extends ZClient.Driver[Any, Throwable] { }, ) .toFuture - } yield Response( - status = Status.fromInt(response.status), - headers = Headers.fromIterable(response.headers.map(h => Header.Custom(h(0), h(1)))), - body = FetchBody.fromResponse(response), - ) + } yield { + val respHeaders = Headers.fromIterable(response.headers.map(h => Header.Custom(h(0), h(1)))) + val ct = respHeaders.get(Header.ContentType) + Response( + status = Status.fromInt(response.status), + headers = respHeaders, + body = FetchBody.fromResponse(response, ct.map(Body.ContentType.fromHeader)), + ) + } } } yield response diff --git a/zio-http/jvm/src/main/scala/zio/http/netty/NettyBody.scala b/zio-http/jvm/src/main/scala/zio/http/netty/NettyBody.scala index ff868bdc4..9e3f8cf2d 100644 --- a/zio-http/jvm/src/main/scala/zio/http/netty/NettyBody.scala +++ b/zio-http/jvm/src/main/scala/zio/http/netty/NettyBody.scala @@ -25,7 +25,7 @@ import zio.stream.ZStream import zio.http.Body.UnsafeBytes import zio.http.internal.BodyEncoding -import zio.http.{Body, Boundary, MediaType} +import zio.http.{Body, Boundary, Header, MediaType} import io.netty.buffer.{ByteBuf, ByteBufUtil} import io.netty.util.AsciiString @@ -40,41 +40,31 @@ object NettyBody extends BodyEncoding { private[zio] def fromAsync( unsafeAsync: UnsafeAsync => Unit, knownContentLength: Option[Long], - contentTypeHeader: Option[String] = None, + contentTypeHeader: Option[Header.ContentType] = None, ): Body = { - val (mediaType, boundary) = mediaTypeAndBoundary(contentTypeHeader) AsyncBody( unsafeAsync, knownContentLength, - mediaType, - boundary, + contentTypeHeader.map(Body.ContentType.fromHeader), ) } /** * Helper to create Body from ByteBuf */ - private[zio] def fromByteBuf(byteBuf: ByteBuf, contentTypeHeader: Option[String]): Body = { + private[zio] def fromByteBuf(byteBuf: ByteBuf, contentTypeHeader: Option[Header.ContentType]): Body = { if (byteBuf.readableBytes() == 0) Body.EmptyBody else { - val (mediaType, boundary) = mediaTypeAndBoundary(contentTypeHeader) - Body.ArrayBody(ByteBufUtil.getBytes(byteBuf), mediaType, boundary) + Body.ArrayBody(ByteBufUtil.getBytes(byteBuf), contentTypeHeader.map(Body.ContentType.fromHeader)) } } - private def mediaTypeAndBoundary(contentTypeHeader: Option[String]) = { - val mediaType = contentTypeHeader.flatMap(MediaType.forContentType) - val boundary = mediaType.flatMap(_.parameters.get("boundary")).map(Boundary(_)) - (mediaType, boundary) - } - override def fromCharSequence(charSequence: CharSequence, charset: Charset): Body = fromAsciiString(new AsciiString(charSequence, charset)) private[zio] final case class AsciiStringBody( asciiString: AsciiString, - override val mediaType: Option[MediaType] = None, - override val boundary: Option[Boundary] = None, + override val contentType: Option[Body.ContentType] = None, ) extends Body with UnsafeBytes { @@ -94,10 +84,7 @@ object NettyBody extends BodyEncoding { private[zio] override def unsafeAsArray(implicit unsafe: Unsafe): Array[Byte] = asciiString.array() - override def contentType(newMediaType: MediaType): Body = copy(mediaType = Some(newMediaType)) - - override def contentType(newMediaType: MediaType, newBoundary: Boundary): Body = - copy(mediaType = Some(newMediaType), boundary = Some(newBoundary)) + override def contentType(newContentType: Body.ContentType): Body = copy(contentType = Some(newContentType)) override def knownContentLength: Option[Long] = Some(asciiString.length().toLong) } @@ -105,8 +92,7 @@ object NettyBody extends BodyEncoding { private[zio] final case class AsyncBody( unsafeAsync: UnsafeAsync => Unit, knownContentLength: Option[Long], - override val mediaType: Option[MediaType] = None, - override val boundary: Option[Boundary] = None, + override val contentType: Option[Body.ContentType] = None, ) extends Body { override def asArray(implicit trace: Trace): Task[Array[Byte]] = asChunk.map { @@ -150,10 +136,7 @@ object NettyBody extends BodyEncoding { override def toString(): String = s"AsyncBody($unsafeAsync)" - override def contentType(newMediaType: MediaType): Body = copy(mediaType = Some(newMediaType)) - - override def contentType(newMediaType: MediaType, newBoundary: Boundary): Body = - copy(mediaType = Some(newMediaType), boundary = Some(newBoundary)) + override def contentType(newContentType: Body.ContentType): Body = copy(contentType = Some(newContentType)) } private[zio] trait UnsafeAsync { diff --git a/zio-http/jvm/src/main/scala/zio/http/netty/NettyBodyWriter.scala b/zio-http/jvm/src/main/scala/zio/http/netty/NettyBodyWriter.scala index 3a39498cd..22bc3aba2 100644 --- a/zio-http/jvm/src/main/scala/zio/http/netty/NettyBodyWriter.scala +++ b/zio-http/jvm/src/main/scala/zio/http/netty/NettyBodyWriter.scala @@ -55,12 +55,12 @@ object NettyBodyWriter { } body match { - case body: FileBody => + case body: FileBody => // We need to stream the file when compression is enabled otherwise the response encoding fails val stream = ZStream.fromFile(body.file) - val s = StreamBody(stream, None, mediaType = body.mediaType) + val s = StreamBody(stream, None, contentType = body.contentType) NettyBodyWriter.writeAndFlush(s, None, ctx) - case AsyncBody(async, _, _, _) => + case AsyncBody(async, _, _) => async( new UnsafeAsync { override def apply(message: Chunk[Byte], isLast: Boolean): Unit = { @@ -76,10 +76,10 @@ object NettyBodyWriter { }, ) None - case AsciiStringBody(asciiString, _, _) => + case AsciiStringBody(asciiString, _) => writeArray(asciiString.array(), isLast = true) None - case StreamBody(stream, _, _, _) => + case StreamBody(stream, _, _) => Some( contentLength.orElse(body.knownContentLength) match { case Some(length) => @@ -120,13 +120,13 @@ object NettyBodyWriter { } }, ) - case ArrayBody(data, _, _) => + case ArrayBody(data, _) => writeArray(data, isLast = true) None - case ChunkBody(data, _, _) => + case ChunkBody(data, _) => writeArray(data.toArray, isLast = true) None - case EmptyBody => + case EmptyBody => ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT) None } diff --git a/zio-http/jvm/src/main/scala/zio/http/netty/NettyResponse.scala b/zio-http/jvm/src/main/scala/zio/http/netty/NettyResponse.scala index 9e68e07a1..8ac8ae218 100644 --- a/zio-http/jvm/src/main/scala/zio/http/netty/NettyResponse.scala +++ b/zio-http/jvm/src/main/scala/zio/http/netty/NettyResponse.scala @@ -33,7 +33,7 @@ object NettyResponse { def apply(jRes: FullHttpResponse)(implicit unsafe: Unsafe): Response = { val status = Conversions.statusFromNetty(jRes.status()) val headers = Conversions.headersFromNetty(jRes.headers()) - val data = NettyBody.fromByteBuf(jRes.content(), headers.headers.get(Header.ContentType.name)) + val data = NettyBody.fromByteBuf(jRes.content(), headers.get(Header.ContentType)) Response(status, headers, data) } @@ -68,7 +68,7 @@ object NettyResponse { val data = NettyBody.fromAsync( callback => responseHandler.connect(callback), knownContentLength, - contentType.map(_.renderedValue), + contentType, ) Response(status, headers, data) } diff --git a/zio-http/jvm/src/main/scala/zio/http/netty/server/ServerInboundHandler.scala b/zio-http/jvm/src/main/scala/zio/http/netty/server/ServerInboundHandler.scala index 4df974e94..f86c06237 100644 --- a/zio-http/jvm/src/main/scala/zio/http/netty/server/ServerInboundHandler.scala +++ b/zio-http/jvm/src/main/scala/zio/http/netty/server/ServerInboundHandler.scala @@ -221,7 +221,7 @@ private[zio] final case class ServerInboundHandler( } val headers = Conversions.headersFromNetty(nettyReq.headers()) - val contentTypeHeader = headers.headers.get(Header.ContentType.name) + val contentTypeHeader = headers.get(Header.ContentType) nettyReq match { case nettyReq: FullHttpRequest => diff --git a/zio-http/jvm/src/test/scala/zio/http/untitled.sc b/zio-http/jvm/src/test/scala/zio/http/untitled.sc new file mode 100644 index 000000000..9448918a5 --- /dev/null +++ b/zio-http/jvm/src/test/scala/zio/http/untitled.sc @@ -0,0 +1,15 @@ +import zio.Chunk +import zio.http.Headers + +new String( + Chunk(45,45,105,110,110,101,114,95,98,111,117,110,100,97,114,121,13,10,99,111,110,116,101,110,116,45,116,121,112,101,58,32,116,101,120,116,47,112,108,97,105,110,13,10,13,10,115,111,109,101,32,116,101,120,116,117,97,108,32,99,111,110,116,101,110,116,13,10,109,97,107,101,32,105,116,32,109,117,108,116,105,108,105,110,101,32,116,101,120,116,117,97,108,32,99,111,110,116,101,110,116,33,13,10,98,117,116,32,110,111,116,32,101,110,100,105,110,103,32,119,105,116,104,32,97,32,67,82,76,70,13,10) + .map(_.toByte).toArray +) + +new String( + Chunk(45,45,40,40,40,98,97,52,53,56,52,102,56,45,99,97,49,54,45,52,53,55,97,45,57,50,49,101,45,97,100,102,49,97,48,49,57,57,54,97,55,41,41,41,13,10,13,10,13,10) + .map(_.toByte).toArray +) + + +Headers() == Headers.empty \ No newline at end of file diff --git a/zio-http/shared/src/main/scala/zio/http/Body.scala b/zio-http/shared/src/main/scala/zio/http/Body.scala index c3655f7ce..c625e83ee 100644 --- a/zio-http/shared/src/main/scala/zio/http/Body.scala +++ b/zio-http/shared/src/main/scala/zio/http/Body.scala @@ -170,20 +170,34 @@ trait Body { self => */ def isEmpty: Boolean + def contentType: Option[Body.ContentType] + + def contentType(newContentType: Body.ContentType): Body + /** * Returns the media type for this Body */ - def mediaType: Option[MediaType] + final def mediaType: Option[MediaType] = + contentType.map(_.mediaType) /** * Updates the media type attached to this body, returning a new Body with the * updated media type */ - def contentType(newMediaType: MediaType): Body + final def contentType(newMediaType: MediaType): Body = + this contentType { + this.contentType + .map(_.copy(mediaType = newMediaType)) + .getOrElse(Body.ContentType(newMediaType)) + } - def contentType(newMediaType: MediaType, newBoundary: Boundary): Body + final def contentType(newMediaType: MediaType, newBoundary: Boundary): Body = + contentType( + Body.ContentType(newMediaType, Some(newBoundary)), + ) - private[zio] def boundary: Option[Boundary] + private[zio] final def boundary: Option[Boundary] = + contentType.flatMap(_.boundary) } @@ -194,6 +208,20 @@ object Body { */ val empty: Body = EmptyBody + final case class ContentType( + mediaType: MediaType, + boundary: Option[Boundary] = None, + charset: Option[Charset] = None, + ) { + def asHeader: Header.ContentType = + Header.ContentType(mediaType, boundary, charset) + } + + object ContentType { + def fromHeader(h: Header.ContentType): ContentType = + ContentType(h.mediaType, h.boundary, h.charset) + } + /** * Constructs a [[zio.http.Body]] from a value based on a zio-schema * [[zio.schema.codec.BinaryCodec]].
Example for json: @@ -226,7 +254,11 @@ object Body { * Constructs a [[zio.http.Body]] from a chunk of bytes and sets the media * type. */ - def fromChunk(data: Chunk[Byte], mediaType: MediaType): Body = ChunkBody(data, mediaType = Some(mediaType)) + def fromChunk(data: Chunk[Byte], mediaType: MediaType): Body = + fromChunk(data, Body.ContentType(mediaType)) + + def fromChunk(data: Chunk[Byte], contentType: Body.ContentType): Body = + ChunkBody(data, Some(contentType)) /** * Constructs a [[zio.http.Body]] from an array of bytes. @@ -254,7 +286,13 @@ object Body { )(implicit trace: Trace): Body = { val bytes = form.multipartBytes(specificBoundary) - StreamBody(bytes, knownContentLength = None, Some(MediaType.multipart.`form-data`), Some(specificBoundary)) + StreamBody( + bytes, + knownContentLength = None, + Some( + Body.ContentType(MediaType.multipart.`form-data`, Some(specificBoundary)), + ), + ) } /** @@ -266,7 +304,11 @@ object Body { form: Form, )(implicit trace: Trace): UIO[Body] = form.multipartBytesUUID.map { case (boundary, bytes) => - StreamBody(bytes, knownContentLength = None, Some(MediaType.multipart.`form-data`), Some(boundary)) + StreamBody( + bytes, + knownContentLength = None, + Some(Body.ContentType(MediaType.multipart.`form-data`, Some(boundary))), + ) } /** @@ -365,21 +407,15 @@ object Body { override private[zio] def unsafeAsArray(implicit unsafe: Unsafe): Array[Byte] = Array.empty[Byte] - override private[zio] def boundary: Option[Boundary] = None - - override def mediaType: Option[MediaType] = None - - override def contentType(newMediaType: MediaType): Body = EmptyBody - - override def contentType(newMediaType: MediaType, newBoundary: Boundary): Body = EmptyBody + override def contentType(newContentType: Body.ContentType): Body = this + override def contentType: Option[Body.ContentType] = None override def knownContentLength: Option[Long] = Some(0L) } private[zio] final case class ChunkBody( data: Chunk[Byte], - override val mediaType: Option[MediaType] = None, - override val boundary: Option[Boundary] = None, + override val contentType: Option[Body.ContentType] = None, ) extends Body with UnsafeBytes { self => @@ -398,18 +434,14 @@ object Body { override private[zio] def unsafeAsArray(implicit unsafe: Unsafe): Array[Byte] = data.toArray - override def contentType(newMediaType: MediaType): Body = copy(mediaType = Some(newMediaType)) - - override def contentType(newMediaType: MediaType, newBoundary: Boundary): Body = - copy(mediaType = Some(newMediaType), boundary = boundary.orElse(Some(newBoundary))) + override def contentType(newContentType: Body.ContentType): Body = copy(contentType = Some(newContentType)) override def knownContentLength: Option[Long] = Some(data.length.toLong) } private[zio] final case class ArrayBody( data: Array[Byte], - override val mediaType: Option[MediaType] = None, - override val boundary: Option[Boundary] = None, + override val contentType: Option[Body.ContentType] = None, ) extends Body with UnsafeBytes { self => @@ -428,10 +460,7 @@ object Body { override private[zio] def unsafeAsArray(implicit unsafe: Unsafe): Array[Byte] = data - override def contentType(newMediaType: MediaType): Body = copy(mediaType = Some(newMediaType)) - - override def contentType(newMediaType: MediaType, newBoundary: Boundary): Body = - copy(mediaType = Some(newMediaType), boundary = boundary.orElse(Some(newBoundary))) + override def contentType(newContentType: Body.ContentType): Body = copy(contentType = Some(newContentType)) override def knownContentLength: Option[Long] = Some(data.length.toLong) } @@ -440,8 +469,7 @@ object Body { file: java.io.File, chunkSize: Int = 1024 * 4, fileSize: Long, - override val mediaType: Option[MediaType] = None, - override val boundary: Option[Boundary] = None, + override val contentType: Option[Body.ContentType] = None, ) extends Body { override def asArray(implicit trace: Trace): Task[Array[Byte]] = ZIO.attemptBlocking { @@ -474,10 +502,7 @@ object Body { .ensuring(ZIO.attemptBlocking(fs.close()).ignoreLogged) }.flattenChunks - override def contentType(newMediaType: MediaType): Body = copy(mediaType = Some(newMediaType)) - - override def contentType(newMediaType: MediaType, newBoundary: Boundary): Body = - copy(mediaType = Some(newMediaType), boundary = boundary.orElse(Some(newBoundary))) + override def contentType(newContentType: Body.ContentType): Body = copy(contentType = Some(newContentType)) override def knownContentLength: Option[Long] = Some(fileSize) } @@ -485,8 +510,7 @@ object Body { private[zio] final case class StreamBody( stream: ZStream[Any, Throwable, Byte], knownContentLength: Option[Long], - override val mediaType: Option[MediaType] = None, - override val boundary: Option[Boundary] = None, + override val contentType: Option[Body.ContentType] = None, ) extends Body { override def asArray(implicit trace: Trace): Task[Array[Byte]] = asChunk.map(_.toArray) @@ -499,10 +523,7 @@ object Body { override def asStream(implicit trace: Trace): ZStream[Any, Throwable, Byte] = stream - override def contentType(newMediaType: MediaType): Body = copy(mediaType = Some(newMediaType)) - - override def contentType(newMediaType: MediaType, newBoundary: Boundary): Body = - copy(mediaType = Some(newMediaType), boundary = boundary.orElse(Some(newBoundary))) + override def contentType(newContentType: Body.ContentType): Body = copy(contentType = Some(newContentType)) } private[zio] final case class WebsocketBody(socketApp: WebSocketApp[Any]) extends Body { @@ -515,17 +536,13 @@ object Body { def asStream(implicit trace: Trace): ZStream[Any, Throwable, Byte] = ZStream.empty - private[zio] def boundary: Option[Boundary] = None - def isComplete: Boolean = true def isEmpty: Boolean = true - def mediaType: Option[MediaType] = None - - def contentType(newMediaType: zio.http.MediaType): zio.http.Body = this + def contentType: Option[Body.ContentType] = None - def contentType(newMediaType: zio.http.MediaType, newBoundary: zio.http.Boundary): zio.http.Body = this + def contentType(newContentType: Body.ContentType): zio.http.Body = this override def knownContentLength: Option[Long] = Some(0L) From 025bf90fe8dabcb4d645be6845e90682bc90a525 Mon Sep 17 00:00:00 2001 From: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> Date: Wed, 31 Jul 2024 16:47:31 +0200 Subject: [PATCH 6/6] Remove deprecated App (#2984) (#2990) --- .../src/main/scala/zio/http/Handler.scala | 14 -- .../src/main/scala/zio/http/HttpApp.scala | 156 ------------------ .../src/main/scala/zio/http/Route.scala | 3 - .../src/main/scala/zio/http/Routes.scala | 1 - .../src/main/scala/zio/http/Server.scala | 22 --- .../main/scala/zio/http/WebSocketApp.scala | 4 - 6 files changed, 200 deletions(-) delete mode 100644 zio-http/shared/src/main/scala/zio/http/HttpApp.scala diff --git a/zio-http/shared/src/main/scala/zio/http/Handler.scala b/zio-http/shared/src/main/scala/zio/http/Handler.scala index 2fec39a6a..a53b87a9b 100644 --- a/zio-http/shared/src/main/scala/zio/http/Handler.scala +++ b/zio-http/shared/src/main/scala/zio/http/Handler.scala @@ -602,20 +602,6 @@ sealed trait Handler[-R, +Err, -In, +Out] { self => self(request).timeout(duration).map(_.getOrElse(out)) } - /** - * Converts the request handler into an HTTP application. Note that the - * handler of the HTTP application is not identical to this handler, because - * the handler has been appropriately sandboxed, turning all possible failures - * into well-formed HTTP responses. - */ - @deprecated("Use toRoutes instead. Will be removed in the next release.", "3.0.0-RC7") - def toHttpApp(implicit err: Err <:< Response, in: Request <:< In, out: Out <:< Response, trace: Trace): HttpApp[R] = { - val handler: Handler[R, Response, Request, Response] = - self.asInstanceOf[Handler[R, Response, Request, Response]] - - HttpApp(Routes.singleton(handler.contramap[(Path, Request)](_._2))) - } - /** * Converts the request handler into an HTTP application. Note that the * handler of the HTTP application is not identical to this handler, because diff --git a/zio-http/shared/src/main/scala/zio/http/HttpApp.scala b/zio-http/shared/src/main/scala/zio/http/HttpApp.scala deleted file mode 100644 index 175abe47d..000000000 --- a/zio-http/shared/src/main/scala/zio/http/HttpApp.scala +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright 2023 the ZIO HTTP contributors. - * - * 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 zio.http - -import zio._ -import zio.stacktracer.TracingImplicits.disableAutoTrace - -import zio.http.Routes.Tree - -/** - * An HTTP application is a collection of routes, all of whose errors have been - * handled through conversion into HTTP responses. - * - * HTTP applications can be installed into a [[zio.http.Server]], which is - * capable of using them to serve requests. - */ -@deprecated("Use Routes instead. Will be removed in the next release.", "3.0.0-RC7") -final case class HttpApp[-Env](routes: Routes[Env, Response]) - extends PartialFunction[Request, ZIO[Env, Response, Response]] { self => - private var _tree: HttpApp.Tree[_] = null.asInstanceOf[HttpApp.Tree[_]] - - /** - * Applies the specified route aspect to every route in the HTTP application. - */ - def @@[Env1 <: Env](aspect: Middleware[Env1]): HttpApp[Env1] = - copy(routes = routes @@ aspect) - - /** - * Combines this HTTP application with the specified HTTP application. In case - * of route conflicts, the routes in this HTTP application take precedence - * over the routes in the specified HTTP application. - */ - def ++[Env1 <: Env](that: HttpApp[Env1]): HttpApp[Env1] = - copy(routes = routes ++ that.routes) - - /** - * Executes the HTTP application with the specified request input, returning - * an effect that will either succeed or fail with a Response. - */ - def apply(request: Request): ZIO[Env, Response, Response] = runZIO(request) - - /** - * Checks to see if the HTTP application may be defined at the specified - * request input. Note that it is still possible for an HTTP application to - * return a 404 Not Found response, which cannot be detected by this method. - * This method only checks for the presence of a handler that handles the - * method and path of the specified request. - */ - def isDefinedAt(request: Request): Boolean = - tree(Trace.empty).get(request.method, request.path).nonEmpty - - /** - * Provides the specified environment to the HTTP application, returning a new - * HTTP application that has no environmental requirements. - */ - def provideEnvironment(env: ZEnvironment[Env]): HttpApp[Any] = - copy(routes = routes.provideEnvironment(env)) - - def run( - method: Method = Method.GET, - path: Path = Path.root, - headers: Headers = Headers.empty, - body: Body = Body.empty, - ): ZIO[Env, Nothing, Response] = - runZIO(Request(method = method, url = URL.root.path(path), headers = headers, body = body)) - - /** - * An alias for `apply`. - */ - def runZIO(request: Request): ZIO[Env, Nothing, Response] = - toHandler(request) - - /** - * Returns a new HTTP application whose requests will be timed out after the - * specified duration elapses. - */ - def timeout(duration: Duration)(implicit trace: Trace): HttpApp[Env] = - self @@ Middleware.timeout(duration) - - /** - * Converts the HTTP application into a request handler. - */ - val toHandler: Handler[Env, Nothing, Request, Response] = { - implicit val trace: Trace = Trace.empty - Handler - .fromFunctionHandler[Request] { req => - val chunk = tree.get(req.method, req.path) - - if (chunk.length == 0) Handler.notFound - else if (chunk.length == 1) chunk(0) - else { - // TODO: Support precomputed fallback among all chunk elements: - chunk.tail.foldLeft(chunk.head) { (acc, h) => - acc.catchAll { response => - if (response.status == Status.NotFound) h - else Handler.fail(response) - } - } - } - } - .merge - } - - /** - * Accesses the underlying tree that provides fast dispatch to handlers. - */ - def tree(implicit trace: Trace): HttpApp.Tree[Env] = { - if (_tree eq null) { - _tree = HttpApp.Tree.fromRoutes(routes) - } - - _tree.asInstanceOf[HttpApp.Tree[Env]] - } -} -object HttpApp { - - /** - * An HTTP application that does not handle any routes. - */ - @deprecated("Use Routes.empty instead. Will be removed in the next release.", "3.0.0-RC7") - val empty: HttpApp[Any] = HttpApp(Routes.empty) - - private[http] final case class Tree[-Env](tree: RoutePattern.Tree[RequestHandler[Env, Response]]) { self => - final def ++[Env1 <: Env](that: Tree[Env1]): Tree[Env1] = - Tree(self.tree ++ that.tree) - - final def add[Env1 <: Env](route: Route[Env1, Response])(implicit trace: Trace): Tree[Env1] = - Tree(self.tree.addAll(route.routePattern.alternatives.map(alt => (alt, route.toHandler)))) - - final def addAll[Env1 <: Env](routes: Iterable[Route[Env1, Response]])(implicit trace: Trace): Tree[Env1] = - Tree[Env1](self.tree.addAll(routes.map(r => r.routePattern.alternatives.map(alt => (alt, r.toHandler))).flatten)) - - final def get(method: Method, path: Path): Chunk[RequestHandler[Env, Response]] = - tree.get(method, path) - } - private[http] object Tree { - val empty: Tree[Any] = Tree(RoutePattern.Tree.empty) - - def fromRoutes[Env](routes: Routes[Env, Response])(implicit trace: Trace): Tree[Env] = - empty.addAll(routes.routes) - } -} diff --git a/zio-http/shared/src/main/scala/zio/http/Route.scala b/zio-http/shared/src/main/scala/zio/http/Route.scala index a06e612a3..b8205acf8 100644 --- a/zio-http/shared/src/main/scala/zio/http/Route.scala +++ b/zio-http/shared/src/main/scala/zio/http/Route.scala @@ -309,9 +309,6 @@ sealed trait Route[-Env, +Err] { self => def toHandler(implicit ev: Err <:< Response, trace: Trace): Handler[Env, Response, Request, Response] - @deprecated("Use toRoutes instead", "3.0.0-RC7") - final def toHttpApp(implicit ev: Err <:< Response): HttpApp[Env] = toHandler.toHttpApp - final def toRoutes: Routes[Env, Err] = Routes(self) def transform[Env1]( diff --git a/zio-http/shared/src/main/scala/zio/http/Routes.scala b/zio-http/shared/src/main/scala/zio/http/Routes.scala index fc62e18b3..3bebc9295 100644 --- a/zio-http/shared/src/main/scala/zio/http/Routes.scala +++ b/zio-http/shared/src/main/scala/zio/http/Routes.scala @@ -20,7 +20,6 @@ import java.io.File import zio._ -import zio.http.HttpApp.Tree import zio.http.Routes.ApplyContextAspect import zio.http.codec.PathCodec diff --git a/zio-http/shared/src/main/scala/zio/http/Server.scala b/zio-http/shared/src/main/scala/zio/http/Server.scala index 2d5912552..1f01a478d 100644 --- a/zio-http/shared/src/main/scala/zio/http/Server.scala +++ b/zio-http/shared/src/main/scala/zio/http/Server.scala @@ -29,13 +29,6 @@ import zio.http.Server.Config.ResponseCompressionConfig */ trait Server { - /** - * Installs the given HTTP application into the server. - */ - @deprecated("Install Routes instead. Will be removed in the next release.", "3.0.0-RC7") - def install[R](httpApp: HttpApp[R])(implicit trace: Trace, tag: EnvironmentTag[R]): URIO[R, Unit] = - install(httpApp.routes) - /** * Installs the given HTTP application into the server. */ @@ -384,21 +377,6 @@ object Server extends ServerPlatformSpecific { } } - @deprecated("Serve Routes instead. Will be removed in the next release.", "3.0.0-RC7") - def serve[R]( - httpApp: HttpApp[R], - )(implicit trace: Trace, tag: EnvironmentTag[R]): URIO[R with Server, Nothing] = { - ZIO.logInfo("Starting the server...") *> - install[R](httpApp) *> - ZIO.logInfo("Server started") *> - ZIO.never - } - - @deprecated("Install Routes instead. Will be removed in the next release.", "3.0.0-RC7") - def install[R](httpApp: HttpApp[R])(implicit trace: Trace, tag: EnvironmentTag[R]): URIO[R with Server, Int] = { - ZIO.serviceWithZIO[Server](_.install[R](httpApp)) *> ZIO.serviceWithZIO[Server](_.port) - } - def serve[R]( httpApp: Routes[R, Response], )(implicit trace: Trace, tag: EnvironmentTag[R]): URIO[R with Server, Nothing] = { diff --git a/zio-http/shared/src/main/scala/zio/http/WebSocketApp.scala b/zio-http/shared/src/main/scala/zio/http/WebSocketApp.scala index ec4176b83..6b40d454b 100644 --- a/zio-http/shared/src/main/scala/zio/http/WebSocketApp.scala +++ b/zio-http/shared/src/main/scala/zio/http/WebSocketApp.scala @@ -72,10 +72,6 @@ final case class WebSocketApp[-R]( Response.fromSocketApp(self.provideEnvironment(env)) } - @deprecated("Use toRoutes. Will be removed in the next release.", "3.0.0-RC7") - def toHttpAppWS(implicit trace: Trace): HttpApp[R] = - Handler.fromZIO(self.toResponse).toHttpApp - def toRoutes(implicit trace: Trace): Routes[R, Response] = Handler.fromZIO(self.toResponse).toRoutes