diff --git a/zio-http/src/main/scala/zhttp/http/Http.scala b/zio-http/src/main/scala/zhttp/http/Http.scala index 79701369f3..da986539c8 100644 --- a/zio-http/src/main/scala/zhttp/http/Http.scala +++ b/zio-http/src/main/scala/zhttp/http/Http.scala @@ -1,5 +1,6 @@ package zhttp.http +import io.netty.buffer.{ByteBuf, ByteBufUtil} import io.netty.channel.ChannelHandler import zhttp.html.Html import zhttp.http.headers.HeaderModifier @@ -170,6 +171,40 @@ sealed trait Http[-R, +E, -A, +B] extends (A => ZIO[R, Option[E], B]) { self => dd: Http[R1, E1, A1, B1], ): Http[R1, E1, A1, B1] = Http.FoldHttp(self, ee, bb, dd) + /** + * Extracts body + */ + final def getBody(implicit eb: IsResponse[B], ee: E <:< Throwable): Http[R, Throwable, A, Chunk[Byte]] = + self.getBodyAsByteBuf.mapZIO(buf => Task(Chunk.fromArray(ByteBufUtil.getBytes(buf)))) + + /** + * Extracts body as a string + */ + final def getBodyAsString(implicit eb: IsResponse[B], ee: E <:< Throwable): Http[R, Throwable, A, String] = + self.getBodyAsByteBuf.mapZIO(bytes => Task(bytes.toString(HTTP_CHARSET))) + + /** + * Extracts content-length from the response if available + */ + final def getContentLength(implicit eb: IsResponse[B]): Http[R, E, A, Option[Long]] = + getHeaders.map(_.getContentLength) + + /** + * Extracts the value of the provided header name. + */ + final def getHeaderValue(name: CharSequence)(implicit eb: IsResponse[B]): Http[R, E, A, Option[CharSequence]] = + getHeaders.map(_.getHeaderValue(name)) + + /** + * Extracts the `Headers` from the type `B` if possible + */ + final def getHeaders(implicit eb: IsResponse[B]): Http[R, E, A, Headers] = self.map(eb.getHeaders) + + /** + * Extracts `Status` from the type `B` is possible. + */ + final def getStatus(implicit ev: IsResponse[B]): Http[R, E, A, Status] = self.map(ev.getStatus) + /** * Transforms the output of the http app */ @@ -313,8 +348,8 @@ sealed trait Http[-R, +E, -A, +B] extends (A => ZIO[R, Option[E], B]) { self => /** * Widens the type of the output */ - final def widen[B1](implicit ev: B <:< B1): Http[R, E, A, B1] = - self.asInstanceOf[Http[R, E, A, B1]] + final def widen[E1, B1](implicit e: E <:< E1, b: B <:< B1): Http[R, E1, A, B1] = + self.asInstanceOf[Http[R, E1, A, B1]] /** * Combines the two apps and returns the result of the one on the right @@ -351,6 +386,15 @@ sealed trait Http[-R, +E, -A, +B] extends (A => ZIO[R, Option[E], B]) { self => case RunMiddleware(app, mid) => mid(app).execute(a) } + + /** + * Extracts body as a ByteBuf + */ + private[zhttp] final def getBodyAsByteBuf(implicit + eb: IsResponse[B], + ee: E <:< Throwable, + ): Http[R, Throwable, A, ByteBuf] = + self.widen[Throwable, B].mapZIO(eb.getBodyAsByteBuf) } object Http { @@ -633,12 +677,12 @@ object Http { dd: Http[R, EE, A, BB], ) extends Http[R, EE, A, BB] - private case object Empty extends Http[Any, Nothing, Any, Nothing] - - private case object Identity extends Http[Any, Nothing, Any, Nothing] - private final case class RunMiddleware[R, E, A1, B1, A2, B2]( http: Http[R, E, A1, B1], mid: Middleware[R, E, A1, B1, A2, B2], ) extends Http[R, E, A2, B2] + + private case object Empty extends Http[Any, Nothing, Any, Nothing] + + private case object Identity extends Http[Any, Nothing, Any, Nothing] } diff --git a/zio-http/src/main/scala/zhttp/http/IsResponse.scala b/zio-http/src/main/scala/zhttp/http/IsResponse.scala new file mode 100644 index 0000000000..8f68acf23c --- /dev/null +++ b/zio-http/src/main/scala/zhttp/http/IsResponse.scala @@ -0,0 +1,25 @@ +package zhttp.http + +import io.netty.buffer.ByteBuf +import zhttp.service.Client.ClientResponse +import zio.Task + +sealed trait IsResponse[-A] { + def getBodyAsByteBuf(a: A): Task[ByteBuf] + def getHeaders(a: A): Headers + def getStatus(a: A): Status +} + +object IsResponse { + implicit object serverResponse extends IsResponse[Response] { + def getBodyAsByteBuf(a: Response): Task[ByteBuf] = a.getBodyAsByteBuf + def getHeaders(a: Response): Headers = a.headers + def getStatus(a: Response): Status = a.status + } + + implicit object clientResponse extends IsResponse[ClientResponse] { + def getBodyAsByteBuf(a: ClientResponse): Task[ByteBuf] = a.getBodyAsByteBuf + def getHeaders(a: ClientResponse): Headers = a.headers + def getStatus(a: ClientResponse): Status = a.status + } +} diff --git a/zio-http/src/main/scala/zhttp/http/Response.scala b/zio-http/src/main/scala/zhttp/http/Response.scala index b59d13b08a..738aced8c3 100644 --- a/zio-http/src/main/scala/zhttp/http/Response.scala +++ b/zio-http/src/main/scala/zhttp/http/Response.scala @@ -1,6 +1,6 @@ package zhttp.http -import io.netty.buffer.Unpooled +import io.netty.buffer.{ByteBuf, Unpooled} import io.netty.handler.codec.http.HttpVersion.HTTP_1_1 import io.netty.handler.codec.http.{HttpHeaderNames, HttpResponse} import zhttp.core.Util @@ -8,7 +8,7 @@ import zhttp.html.Html import zhttp.http.HttpError.HTTPErrorWithCause import zhttp.http.headers.HeaderExtension import zhttp.socket.{IsWebSocket, Socket, SocketApp} -import zio.{Chunk, UIO, ZIO} +import zio.{Chunk, Task, UIO, ZIO} import java.nio.charset.Charset import java.nio.file.Files @@ -66,6 +66,11 @@ final case class Response private ( */ def wrapZIO: UIO[Response] = UIO(self) + /** + * Extracts the body as ByteBuf + */ + private[zhttp] def getBodyAsByteBuf: Task[ByteBuf] = self.data.toByteBuf + /** * Encodes the Response into a Netty HttpResponse. Sets default headers such as `content-length`. For performance * reasons, it is possible that it uses a FullHttpResponse if the complete data is available. Otherwise, it would diff --git a/zio-http/src/main/scala/zhttp/service/Client.scala b/zio-http/src/main/scala/zhttp/service/Client.scala index 74d074172e..fb9afde801 100644 --- a/zio-http/src/main/scala/zhttp/service/Client.scala +++ b/zio-http/src/main/scala/zhttp/service/Client.scala @@ -176,13 +176,15 @@ object Client { self.copy(getHeaders = update(self.getHeaders)) } - final case class ClientResponse(status: Status, headers: Headers, private val buffer: ByteBuf) + final case class ClientResponse(status: Status, headers: Headers, private[zhttp] val buffer: ByteBuf) extends HeaderExtension[ClientResponse] { self => - def getBodyAsString: Task[String] = Task(buffer.toString(self.getCharset)) - def getBody: Task[Chunk[Byte]] = Task(Chunk.fromArray(ByteBufUtil.getBytes(buffer))) + def getBodyAsByteBuf: Task[ByteBuf] = Task(buffer) + + def getBodyAsString: Task[String] = Task(buffer.toString(self.getCharset)) + override def getHeaders: Headers = headers override def updateHeaders(update: Headers => Headers): ClientResponse = self.copy(headers = update(headers)) diff --git a/zio-http/src/test/scala/zhttp/internal/HttpRunnableSpec.scala b/zio-http/src/test/scala/zhttp/internal/HttpRunnableSpec.scala index 8fac5983fa..1642799a76 100644 --- a/zio-http/src/test/scala/zhttp/internal/HttpRunnableSpec.scala +++ b/zio-http/src/test/scala/zhttp/internal/HttpRunnableSpec.scala @@ -1,24 +1,92 @@ package zhttp.internal +import sttp.client3 import sttp.client3.asynchttpclient.zio.{SttpClient, send} -import sttp.client3.{Response => SResponse, UriContext, asWebSocketUnsafe, basicRequest} +import sttp.client3.{UriContext, asWebSocketUnsafe, basicRequest} import sttp.model.{Header => SHeader} import sttp.ws.WebSocket import zhttp.http.URL.Location import zhttp.http._ import zhttp.internal.DynamicServer.HttpEnv -import zhttp.internal.HttpRunnableSpec.HttpIO +import zhttp.internal.HttpRunnableSpec.HttpTestClient import zhttp.service._ import zhttp.service.client.ClientSSLHandler.ClientSSLOptions import zio.test.DefaultRunnableSpec -import zio.{Chunk, Has, Task, ZIO, ZManaged} +import zio.{Has, Task, ZIO, ZManaged} /** - * Should be used only when e2e tests needs to be written which is typically for logic that is part of the netty based - * backend. For most of the other use cases directly running the HttpApp should suffice. HttpRunnableSpec spins of an - * actual Http server and makes requests. + * Should be used only when e2e tests needs to be written. Typically we would want to do that when we want to test the + * logic that is part of the netty based backend. For most of the other use cases directly running the HttpApp should + * suffice. HttpRunnableSpec spins of an actual Http server and makes requests. */ abstract class HttpRunnableSpec extends DefaultRunnableSpec { self => + + implicit class RunnableClientHttpSyntax[R, A](app: Http[R, Throwable, Client.ClientRequest, A]) { + + /** + * Runs the deployed Http app by making a real http request to it. The method allows us to configure individual + * constituents of a ClientRequest. + */ + def run( + path: Path = !!, + method: Method = Method.GET, + content: String = "", + headers: Headers = Headers.empty, + ): ZIO[R, Throwable, A] = + app( + Client.ClientRequest( + method, + URL(path, Location.Absolute(Scheme.HTTP, "localhost", 0)), + headers, + HttpData.fromString(content), + ), + ).catchAll { + case Some(value) => ZIO.fail(value) + case None => ZIO.fail(new RuntimeException("No response")) + } + } + + implicit class RunnableHttpClientAppSyntax(app: HttpApp[HttpEnv, Throwable]) { + + /** + * Deploys the http application on the test server and returns a Http of type + * {{{Http[R, E, ClientRequest, ClientResponse}}}. This allows us to assert using all the powerful operators that + * are available on `Http` while writing tests. It also allows us to simply pass a request in the end, to execute, + * and resolve it with a response, like a normal HttpApp. + */ + def deploy: HttpTestClient[Any, Client.ClientResponse] = + for { + port <- Http.fromZIO(DynamicServer.getPort) + id <- Http.fromZIO(DynamicServer.deploy(app)) + response <- Http.fromFunctionZIO[Client.ClientRequest] { params => + Client.request( + params + .addHeader(DynamicServer.APP_ID, id) + .copy(url = URL(params.url.path, Location.Absolute(Scheme.HTTP, "localhost", port))), + ClientSSLOptions.DefaultSSL, + ) + } + } yield response + + /** + * Deploys the websocket application on the test server. + */ + def deployWebSocket: HttpTestClient[SttpClient, client3.Response[Either[String, WebSocket[Task]]]] = for { + id <- Http.fromZIO(DynamicServer.deploy(app)) + res <- + Http.fromFunctionZIO[Client.ClientRequest](params => + for { + port <- DynamicServer.getPort + url = s"ws://localhost:$port${params.url.path.asString}" + headerConv = params.addHeader(DynamicServer.APP_ID, id).getHeaders.toList.map(h => SHeader(h._1, h._2)) + res <- send(basicRequest.get(uri"$url").copy(headers = headerConv).response(asWebSocketUnsafe)) + } yield res, + ) + + } yield res + + } + def serve[R <: Has[_]]( app: HttpApp[R, Throwable], ): ZManaged[R with EventLoopGroup with ServerChannelFactory with DynamicServer, Nothing, Unit] = @@ -27,23 +95,10 @@ abstract class HttpRunnableSpec extends DefaultRunnableSpec { self => _ <- DynamicServer.setStart(start).toManaged_ } yield () - def request( - path: Path = !!, + def status( method: Method = Method.GET, - content: String = "", - headers: Headers = Headers.empty, - ): HttpIO[Any, Client.ClientResponse] = { - for { - port <- DynamicServer.getPort - data = HttpData.fromString(content) - response <- Client.request( - Client.ClientRequest(method, URL(path, Location.Absolute(Scheme.HTTP, "localhost", port)), headers, data), - ClientSSLOptions.DefaultSSL, - ) - } yield response - } - - def status(method: Method = Method.GET, path: Path): HttpIO[Any, Status] = { + path: Path, + ): ZIO[EventLoopGroup with ChannelFactory with DynamicServer, Throwable, Status] = { for { port <- DynamicServer.getPort status <- Client @@ -55,84 +110,14 @@ abstract class HttpRunnableSpec extends DefaultRunnableSpec { self => .map(_.status) } yield status } - - def webSocketRequest( - path: Path = !!, - headers: Headers = Headers.empty, - ): HttpIO[SttpClient, SResponse[Either[String, WebSocket[Task]]]] = { - // todo: uri should be created by using URL().asString but currently support for ws Scheme is missing - for { - port <- DynamicServer.getPort - url = s"ws://localhost:$port${path.asString}" - headerConv: List[SHeader] = headers.toList.map(h => SHeader(h._1, h._2)) - res <- send(basicRequest.get(uri"$url").copy(headers = headerConv).response(asWebSocketUnsafe)) - } yield res - } - - implicit class RunnableHttpAppSyntax(app: HttpApp[HttpEnv, Throwable]) { - def deploy: ZIO[DynamicServer, Nothing, String] = DynamicServer.deploy(app) - - def request( - path: Path = !!, - method: Method = Method.GET, - content: String = "", - headers: Headers = Headers.empty, - ): HttpIO[Any, Client.ClientResponse] = for { - id <- deploy - response <- self.request(path, method, content, Headers(DynamicServer.APP_ID, id) ++ headers) - } yield response - - def requestBodyAsString( - path: Path = !!, - method: Method = Method.GET, - content: String = "", - headers: Headers = Headers.empty, - ): HttpIO[Any, String] = - request(path, method, content, headers).flatMap(_.getBodyAsString) - - def requestHeaderValueByName( - path: Path = !!, - method: Method = Method.GET, - content: String = "", - headers: Headers = Headers.empty, - )(name: CharSequence): HttpIO[Any, Option[String]] = - request(path, method, content, headers).map(_.getHeaderValue(name)) - - def requestStatus( - path: Path = !!, - method: Method = Method.GET, - content: String = "", - headers: Headers = Headers.empty, - ): HttpIO[Any, Status] = - request(path, method, content, headers).map(_.status) - - def webSocketStatusCode( - path: Path = !!, - headers: Headers = Headers.empty, - ): HttpIO[SttpClient, Int] = for { - id <- deploy - res <- self.webSocketRequest(path, Headers(DynamicServer.APP_ID, id) ++ headers) - } yield res.code.code - - def requestBody( - path: Path = !!, - method: Method = Method.GET, - content: String = "", - headers: Headers = Headers.empty, - ): HttpIO[Any, Chunk[Byte]] = - request(path, method, content, headers).flatMap(_.getBody) - - def requestContentLength( - path: Path = !!, - method: Method = Method.GET, - content: String = "", - headers: Headers = Headers.empty, - ): HttpIO[Any, Option[Long]] = - request(path, method, content, headers).map(_.getContentLength) - } } object HttpRunnableSpec { - type HttpIO[-R, +A] = - ZIO[R with EventLoopGroup with ChannelFactory with DynamicServer with ServerChannelFactory, Throwable, A] + type HttpTestClient[-R, +A] = + Http[ + R with EventLoopGroup with ChannelFactory with DynamicServer with ServerChannelFactory, + Throwable, + Client.ClientRequest, + A, + ] } diff --git a/zio-http/src/test/scala/zhttp/service/ClientSpec.scala b/zio-http/src/test/scala/zhttp/service/ClientSpec.scala index 5424ea5b66..84d8c6b470 100644 --- a/zio-http/src/test/scala/zhttp/service/ClientSpec.scala +++ b/zio-http/src/test/scala/zhttp/service/ClientSpec.scala @@ -15,27 +15,27 @@ object ClientSpec extends HttpRunnableSpec { def clientSpec = suite("ClientSpec") { testM("respond Ok") { - val app = Http.ok.requestStatus() + val app = Http.ok.deploy.getStatus.run() assertM(app)(equalTo(Status.OK)) } + testM("non empty content") { val app = Http.text("abc") - val responseContent = app.requestBody() + val responseContent = app.deploy.getBody.run() assertM(responseContent)(isNonEmpty) } + testM("echo POST request content") { val app = Http.collectZIO[Request] { case req => req.getBodyAsString.map(Response.text(_)) } - val res = app.requestBodyAsString(method = Method.POST, content = "ZIO user") + val res = app.deploy.getBodyAsString.run(method = Method.POST, content = "ZIO user") assertM(res)(equalTo("ZIO user")) } + testM("empty content") { val app = Http.empty - val responseContent = app.requestBody() + val responseContent = app.deploy.getBody.run() assertM(responseContent)(isEmpty) } + testM("text content") { val app = Http.text("zio user does not exist") - val responseContent = app.requestBodyAsString() + val responseContent = app.deploy.getBodyAsString.run() assertM(responseContent)(containsString("user")) } } diff --git a/zio-http/src/test/scala/zhttp/service/ServerSpec.scala b/zio-http/src/test/scala/zhttp/service/ServerSpec.scala index 66169ef60c..3c721eff22 100644 --- a/zio-http/src/test/scala/zhttp/service/ServerSpec.scala +++ b/zio-http/src/test/scala/zhttp/service/ServerSpec.scala @@ -1,6 +1,5 @@ package zhttp.service -import io.netty.handler.codec.http.HttpHeaderNames import zhttp.html._ import zhttp.http._ import zhttp.internal.{DynamicServer, HttpGen, HttpRunnableSpec} @@ -42,41 +41,41 @@ object ServerSpec extends HttpRunnableSpec { def dynamicAppSpec = suite("DynamicAppSpec") { suite("success") { testM("status is 200") { - val status = Http.ok.requestStatus() + val status = Http.ok.deploy.getStatus.run() assertM(status)(equalTo(Status.OK)) } + testM("status is 200") { - val res = Http.text("ABC").requestStatus() + val res = Http.text("ABC").deploy.getStatus.run() assertM(res)(equalTo(Status.OK)) } + testM("content is set") { - val res = Http.text("ABC").requestBodyAsString() + val res = Http.text("ABC").deploy.getBodyAsString.run() assertM(res)(containsString("ABC")) } } + suite("not found") { val app = Http.empty testM("status is 404") { - val res = app.requestStatus() + val res = app.deploy.getStatus.run() assertM(res)(equalTo(Status.NOT_FOUND)) } + testM("header is set") { - val res = app.request().map(_.getHeaderValue("Content-Length")) + val res = app.deploy.getHeaderValue(HeaderNames.contentLength).run() assertM(res)(isSome(equalTo("0"))) } } + suite("error") { val app = Http.fail(new Error("SERVER_ERROR")) testM("status is 500") { - val res = app.requestStatus() + val res = app.deploy.getStatus.run() assertM(res)(equalTo(Status.INTERNAL_SERVER_ERROR)) } + testM("content is set") { - val res = app.requestBodyAsString() + val res = app.deploy.getBodyAsString.run() assertM(res)(containsString("SERVER_ERROR")) } + testM("header is set") { - val res = app.request().map(_.getHeaderValue("Content-Length")) + val res = app.deploy.getHeaderValue(HeaderNames.contentLength).run() assertM(res)(isSome(anything)) } } + @@ -86,32 +85,32 @@ object ServerSpec extends HttpRunnableSpec { } testM("status is 200") { - val res = app.requestStatus() + val res = app.deploy.getStatus.run() assertM(res)(equalTo(Status.OK)) } + testM("body is ok") { - val res = app.requestBodyAsString(content = "ABC") + val res = app.deploy.getBodyAsString.run(content = "ABC") assertM(res)(equalTo("ABC")) } + testM("empty string") { - val res = app.requestBodyAsString(content = "") + val res = app.deploy.getBodyAsString.run(content = "") assertM(res)(equalTo("")) } + testM("one char") { - val res = app.requestBodyAsString(content = "1") + val res = app.deploy.getBodyAsString.run(content = "1") assertM(res)(equalTo("1")) } } + suite("headers") { val app = Http.ok.addHeader("Foo", "Bar") testM("headers are set") { - val res = app.request().map(_.getHeaderValue("Foo")) + val res = app.deploy.getHeaderValue("Foo").run() assertM(res)(isSome(equalTo("Bar"))) } } + suite("response") { val app = Http.response(Response(status = Status.OK, data = HttpData.fromString("abc"))) testM("body is set") { - val res = app.requestBodyAsString() + val res = app.deploy.getBodyAsString.run() assertM(res)(equalTo("abc")) } } @@ -123,13 +122,13 @@ object ServerSpec extends HttpRunnableSpec { } testM("has content-length") { checkAllM(Gen.alphaNumericString) { string => - val res = app.requestBodyAsString(content = string) + val res = app.deploy.getBodyAsString.run(content = string) assertM(res)(equalTo(string.length.toString)) } } + testM("POST Request.getBody") { val app = Http.collectZIO[Request] { case req => req.getBody.as(Response.ok) } - val res = app.requestStatus(!!, Method.POST, "some text") + val res = app.deploy.getStatus.run(!!, Method.POST, "some text") assertM(res)(equalTo(Status.OK)) } } @@ -137,13 +136,13 @@ object ServerSpec extends HttpRunnableSpec { def responseSpec = suite("ResponseSpec") { testM("data") { checkAllM(nonEmptyContent) { case (string, data) => - val res = Http.fromData(data).requestBodyAsString() + val res = Http.fromData(data).deploy.getBodyAsString.run() assertM(res)(equalTo(string)) } } + testM("data from file") { val file = new File(getClass.getResource("/TestFile.txt").getPath) - val res = Http.fromFile(file).requestBodyAsString() + val res = Http.fromFile(file).deploy.getBodyAsString.run() assertM(res)(equalTo("abc\nfoo")) } + testM("content-type header on file response") { @@ -151,24 +150,26 @@ object ServerSpec extends HttpRunnableSpec { val res = Http .fromFile(file) - .requestHeaderValueByName()(HttpHeaderNames.CONTENT_TYPE) + .deploy + .getHeaderValue(HeaderNames.contentType) + .run() .map(_.getOrElse("Content type header not found.")) assertM(res)(equalTo("text/plain")) } + testM("status") { - checkAllM(HttpGen.status) { case (status) => - val res = Http.status(status).requestStatus() + checkAllM(HttpGen.status) { case status => + val res = Http.status(status).deploy.getStatus.run() assertM(res)(equalTo(status)) } } + testM("header") { checkAllM(HttpGen.header) { case header @ (name, value) => - val res = Http.ok.addHeader(header).requestHeaderValueByName()(name) + val res = Http.ok.addHeader(header).deploy.getHeaderValue(name).run() assertM(res)(isSome(equalTo(value))) } } + testM("text streaming") { - val res = Http.fromStream(ZStream("a", "b", "c")).requestBodyAsString() + val res = Http.fromStream(ZStream("a", "b", "c")).deploy.getBodyAsString.run() assertM(res)(equalTo("abc")) } + testM("echo streaming") { @@ -176,33 +177,35 @@ object ServerSpec extends HttpRunnableSpec { .collectHttp[Request] { case req => Http.fromStream(ZStream.fromEffect(req.getBody).flattenChunks) } - .requestBodyAsString(content = "abc") + .deploy + .getBodyAsString + .run(content = "abc") assertM(res)(equalTo("abc")) } + testM("file-streaming") { val path = getClass.getResource("/TestFile.txt").getPath - val res = Http.fromStream(ZStream.fromFile(Paths.get(path))).requestBodyAsString() + val res = Http.fromStream(ZStream.fromFile(Paths.get(path))).deploy.getBodyAsString.run() assertM(res)(equalTo("abc\nfoo")) } + suite("html") { testM("body") { - val res = Http.html(html(body(div(id := "foo", "bar")))).requestBodyAsString() + val res = Http.html(html(body(div(id := "foo", "bar")))).deploy.getBodyAsString.run() assertM(res)(equalTo("""