Skip to content

Commit

Permalink
Refactor: HttpRunnableSpec clean up (#857)
Browse files Browse the repository at this point in the history
* refactor: reduce HttpRunnableSpec boilerplate

* refactor: remove all helpers from HttpRunnableSpec and inline the method

* doc: update documentation for test module

* refactor: add type-constraints to Http for specialized methods

* refactor: use IsResponse type constraint

* style(*): apply scala fmt

* doc: update documentation
  • Loading branch information
Tushar Mathur authored Jan 25, 2022
1 parent 0ee6dde commit dc0d80f
Show file tree
Hide file tree
Showing 8 changed files with 223 additions and 160 deletions.
56 changes: 50 additions & 6 deletions zio-http/src/main/scala/zhttp/http/Http.scala
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
}
25 changes: 25 additions & 0 deletions zio-http/src/main/scala/zhttp/http/IsResponse.scala
Original file line number Diff line number Diff line change
@@ -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
}
}
9 changes: 7 additions & 2 deletions zio-http/src/main/scala/zhttp/http/Response.scala
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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
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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions zio-http/src/main/scala/zhttp/service/Client.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
183 changes: 84 additions & 99 deletions zio-http/src/test/scala/zhttp/internal/HttpRunnableSpec.scala
Original file line number Diff line number Diff line change
@@ -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] =
Expand All @@ -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
Expand All @@ -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,
]
}
Loading

0 comments on commit dc0d80f

Please sign in to comment.