diff --git a/modules/core/src/smithy4s/http/HttpContractError.scala b/modules/core/src/smithy4s/http/HttpContractError.scala index 32df11c1b..6f67229cf 100644 --- a/modules/core/src/smithy4s/http/HttpContractError.scala +++ b/modules/core/src/smithy4s/http/HttpContractError.scala @@ -70,7 +70,7 @@ object HttpPayloadError { } sealed trait MetadataError extends HttpContractError { - import MetadataError.* + import MetadataError._ override def getMessage(): String = this match { case NotFound(field, location) => diff --git a/modules/core/src/smithy4s/http/HttpResponse.scala b/modules/core/src/smithy4s/http/HttpResponse.scala index 94631ce36..d0f8989b0 100644 --- a/modules/core/src/smithy4s/http/HttpResponse.scala +++ b/modules/core/src/smithy4s/http/HttpResponse.scala @@ -247,10 +247,12 @@ object HttpResponse { response.statusCode, response.headers, bodyBlob.toUTF8String, - Some(FailedDecodeAttempt( - discriminator = discriminator, - contractError = error - )) + Some( + FailedDecodeAttempt( + discriminator = discriminator, + contractError = error + ) + ) ) ) case otherError => F.raiseError(otherError) @@ -263,14 +265,15 @@ object HttpResponse { body = bodyBlob.toUTF8String, failedDecodeAttempt = Some( FailedDecodeAttempt( - discriminator = HttpDiscriminator.StatusCode(response.statusCode), - contractError = HttpPayloadError( - path = smithy4s.codecs.PayloadPath(List()), - expected = "JSON", - message = "Unknown error due to unrecognised discriminator" + discriminator = + HttpDiscriminator.StatusCode(response.statusCode), + contractError = HttpPayloadError( + path = smithy4s.codecs.PayloadPath(List()), + expected = "object", + message = + "Unknown error due to unrecognised discriminator" + ) ) - ) - ) ) ) diff --git a/modules/http4s/test/src/smithy4s/http4s/Http4sPizzaSpec.scala b/modules/http4s/test/src/smithy4s/http4s/Http4sPizzaSpec.scala index 687d1b7c0..e76d08b23 100644 --- a/modules/http4s/test/src/smithy4s/http4s/Http4sPizzaSpec.scala +++ b/modules/http4s/test/src/smithy4s/http4s/Http4sPizzaSpec.scala @@ -19,8 +19,9 @@ package smithy4s.http4s import cats.effect.IO import cats.effect.Resource import org.http4s.Uri -import org.http4s.client.Client +import org.http4s.Response import org.http4s.implicits._ +import org.http4s.client.Client import smithy4s.example.PizzaAdminService object Http4sPizzaSpec extends smithy4s.tests.PizzaSpec { @@ -40,4 +41,24 @@ object Http4sPizzaSpec extends smithy4s.tests.PizzaSpec { } + def runServerWithClient( + pizzaService: PizzaAdminService[IO], + clientResponse: Response[IO] + ): Resource[IO, Res] = { + SimpleRestJsonBuilder + .routes( + SimpleRestJsonBuilder(PizzaAdminService) + .client[IO](Client(_ => Resource.pure(clientResponse))) + .make + .toTry + .get + ) + .resource + .map { httpRoutes => + val client = Client.fromHttpApp(httpRoutes.orNotFound) + val uri = Uri.unsafeFromString("http://localhost") + (client, uri) + } + } + } diff --git a/modules/tests/src/smithy4s/tests/PizzaAdminServiceImpl.scala b/modules/tests/src/smithy4s/tests/PizzaAdminServiceImpl.scala index 12787f5be..3a84cdfcb 100644 --- a/modules/tests/src/smithy4s/tests/PizzaAdminServiceImpl.scala +++ b/modules/tests/src/smithy4s/tests/PizzaAdminServiceImpl.scala @@ -19,6 +19,7 @@ package smithy4s.tests import cats.effect._ import cats.effect.std.UUIDGen import cats.implicits._ +import org.http4s.Response import smithy4s.Timestamp import smithy4s.example._ import smithy4s.tests.PizzaAdminServiceImpl._ @@ -27,7 +28,18 @@ import java.util.UUID object PizzaAdminServiceImpl { case class Item(food: Food, price: Float, addedAt: Timestamp) - case class State(restaurants: Map[String, Restaurant]) + case class State( + restaurants: Map[String, Restaurant], + responses: Map[String, Response[IO]] = Map.empty + ) { + def prepResponse(key: String, response: Response[IO]): State = + copy(responses = responses.updated(key, response)) + + def getResponse(key: String): IO[Response[IO]] = + responses + .get(key) + .liftTo[IO](new Throwable(s"Response not found for key: $key")) + } case class Restaurant(menu: Map[UUID, Item]) case object Boom extends Throwable with scala.util.control.NoStackTrace diff --git a/modules/tests/src/smithy4s/tests/PizzaClientSpec.scala b/modules/tests/src/smithy4s/tests/PizzaClientSpec.scala index ad624a88a..4f6c42f3b 100644 --- a/modules/tests/src/smithy4s/tests/PizzaClientSpec.scala +++ b/modules/tests/src/smithy4s/tests/PizzaClientSpec.scala @@ -124,14 +124,16 @@ abstract class PizzaClientSpec extends IOSuite { 407, Map("Content-Length" -> "42", "Content-Type" -> "application/json"), """{"message":"generic client error message"}""", - Some(FailedDecodeAttempt( - discriminator = HttpDiscriminator.StatusCode(407), - contractError = HttpPayloadError( - path = smithy4s.codecs.PayloadPath(List()), - expected = "JSON", - message = "Unknown error due to unrecognised discriminator" + Some( + FailedDecodeAttempt( + discriminator = HttpDiscriminator.StatusCode(407), + contractError = HttpPayloadError( + path = smithy4s.codecs.PayloadPath(List()), + expected = "object", + message = "Unknown error due to unrecognised discriminator" + ) ) - )) + ) ) ) @@ -141,25 +143,29 @@ abstract class PizzaClientSpec extends IOSuite { .withEntity("goodbye world"), rawErrorResponse( 500, - Map("Content-Length" -> "13", "Content-Type" -> "text/plain; charset=UTF-8"), + Map( + "Content-Length" -> "13", + "Content-Type" -> "text/plain; charset=UTF-8" + ), "goodbye world", - Some(FailedDecodeAttempt( - discriminator = HttpDiscriminator.StatusCode(500), - contractError = HttpPayloadError( - path = smithy4s.codecs.PayloadPath(List()), - expected = "JSON", - message = "Unknown error due to unrecognised discriminator" + Some( + FailedDecodeAttempt( + discriminator = HttpDiscriminator.StatusCode(500), + contractError = HttpPayloadError( + path = smithy4s.codecs.PayloadPath(List()), + expected = "object", + message = "Unknown error due to unrecognised discriminator" + ) ) - )) + ) ) ) - private def rawErrorResponse( - code: Int, - headers: Map[String, String], - body: String, - failedDecodeAttempt: Option[FailedDecodeAttempt] + code: Int, + headers: Map[String, String], + body: String, + failedDecodeAttempt: Option[FailedDecodeAttempt] ): RawErrorResponse = RawErrorResponse( code, diff --git a/modules/tests/src/smithy4s/tests/PizzaSpec.scala b/modules/tests/src/smithy4s/tests/PizzaSpec.scala index b17982521..8d002b5fe 100644 --- a/modules/tests/src/smithy4s/tests/PizzaSpec.scala +++ b/modules/tests/src/smithy4s/tests/PizzaSpec.scala @@ -19,17 +19,22 @@ package smithy4s.tests import cats.data.NonEmptyList import cats.effect._ import cats.syntax.all._ + import io.circe._ -import org.http4s.Request +import org.http4s._ import org.http4s.Uri import org.http4s.circe._ import org.http4s.client.Client +import org.typelevel.ci.CIString import org.http4s.client.dsl.Http4sClientDsl import org.http4s.dsl.Http4sDsl +import org.http4s.Response import smithy4s.http.HttpPayloadError import smithy4s.example.PizzaAdminService import smithy4s.http.CaseInsensitive import smithy4s.http.HttpContractError +import smithy4s.http.HttpDiscriminator +import smithy4s.http.RawErrorResponse import weaver._ import cats.Show import org.http4s.EntityDecoder @@ -44,6 +49,11 @@ abstract class PizzaSpec errorAdapter: PartialFunction[Throwable, Throwable] ): Resource[IO, Res] + def runServerWithClient( + pizzaService: PizzaAdminService[IO], + clientResponse: Response[IO] + ): Resource[IO, Res] + val pizzaItem = Json.obj( "pizza" -> Json.obj( "name" -> Json.fromString("margharita"), @@ -463,6 +473,127 @@ abstract class PizzaSpec } } + routerTest("Negative: handle error without discriminator") { + (client, uri, log) => + val badResponse = org.http4s.Response[IO]( + status = Status.InternalServerError, + body = fs2.Stream.emits("malformed body".getBytes), + headers = Headers( + Header.Raw(CIString("Content-Length"), "14"), + Header.Raw(CIString("Content-Type"), "text/plain") + ) + ) + + for { + stateRef <- IO.ref(PizzaAdminServiceImpl.State(Map.empty)) + impl = new PizzaAdminServiceImpl(stateRef) + response <- runServerWithClient( + impl, + badResponse + ).use { case (client, uri) => + val request = POST( + menuItem, + uri / "restaurant" / "boom" / "menu" / "item" + ) + client + .run(request) + .use { response => + IO.pure(response.status.code) + } + .attempt + } + + } yield { + val httpPayloadError = HttpPayloadError( + path = smithy4s.codecs.PayloadPath(List()), + expected = "object", + message = "Unknown error due to unrecognised discriminator" + ) + + val expectHeaders = Map( + CaseInsensitive("Content-Type") -> List("text/plain"), + CaseInsensitive("Content-Length") -> List("14") + ) + response match { + case Left( + RawErrorResponse(code, headers, body, Some(failedDecodeAttempt)) + ) => + expect(code == 500) && + expect(headers == expectHeaders) && + expect(body.contains("malformed body")) && + expect( + failedDecodeAttempt.discriminator == HttpDiscriminator + .StatusCode(500) + ) && + expect(failedDecodeAttempt.contractError == httpPayloadError) + case _ => + failure("Expected RawErrorResponse with status 500") + } + } + } + + routerTest( + "Negative: handle decoder exception for known error with discriminator" + ) { (client, uri, log) => + val badResponse = org.http4s.Response[IO]( + status = Status.InternalServerError, + body = fs2.Stream.emits("malformed body".getBytes), + headers = Headers( + Header.Raw(CIString("Content-Length"), "14"), + Header.Raw(CIString("Content-Type"), "text/plain"), + Header.Raw(CIString("X-Error-Type"), "GenericServerError") + ) + ) + + for { + stateRef <- IO.ref(PizzaAdminServiceImpl.State(Map.empty)) + impl = new PizzaAdminServiceImpl(stateRef) + response <- runServerWithClient( + impl, + badResponse + ).use { case (client, uri) => + val request = POST( + menuItem, + uri / "restaurant" / "boom" / "menu" / "item" + ) + client + .run(request) + .use { response => + IO.pure(response.status.code) + } + .attempt + + } + + } yield { + val expectHeaders = Map( + CaseInsensitive("Content-Length") -> List("14"), + CaseInsensitive("Content-Type") -> List("text/plain"), + CaseInsensitive("X-Error-Type") -> List("GenericServerError") + ) + response match { + case Left( + RawErrorResponse(code, headers, body, Some(failedDecodeAttempt)) + ) => + expect(code == 500) && + expect(headers == expectHeaders) && + expect(body.contains("malformed body")) && + expect( + failedDecodeAttempt.discriminator == HttpDiscriminator.NameOnly( + "GenericServerError" + ) + ) && + expect( + failedDecodeAttempt.contractError.getMessage.contains( + "Expected JSON object, offset: 0x00000000, buf:" + ) + ) + case _ => + failure("Expected RawErrorResponse with status 500") + } + } + } + type Res = (Client[IO], Uri) def sharedResource: Resource[IO, (Client[IO], Uri)] = for { stateRef <- Resource.eval(