From 805485ba5b553b0f11586366adf60c6b810d1e24 Mon Sep 17 00:00:00 2001 From: Pawel Stawicki Date: Tue, 14 Jan 2025 16:09:50 +0100 Subject: [PATCH 1/3] Added sttp.client3::circe dependency, necessary for a new example --- build.sbt | 1 + 1 file changed, 1 insertion(+) diff --git a/build.sbt b/build.sbt index dbfa6df8ad..971aead72a 100644 --- a/build.sbt +++ b/build.sbt @@ -2065,6 +2065,7 @@ lazy val examples: ProjectMatrix = (projectMatrix in file("examples")) libraryDependencies ++= Seq( "com.softwaremill.sttp.apispec" %% "asyncapi-circe-yaml" % Versions.sttpApispec, "com.softwaremill.sttp.client3" %% "core" % Versions.sttp, + "com.softwaremill.sttp.client3" %% "circe" % Versions.sttp, "com.softwaremill.sttp.client3" %% "pekko-http-backend" % Versions.sttp, "com.softwaremill.sttp.client3" %% "async-http-client-backend-fs2" % Versions.sttp, "com.softwaremill.sttp.client3" %% "async-http-client-backend-zio" % Versions.sttp, From 6de0b0236c2cee52372d40b0728f17704164c370 Mon Sep 17 00:00:00 2001 From: Pawel Stawicki Date: Tue, 14 Jan 2025 16:10:14 +0100 Subject: [PATCH 2/3] Default error handler returning always JSON body --- .../tapir/examples/errors/errorAsJson.scala | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 examples/src/main/scala/sttp/tapir/examples/errors/errorAsJson.scala diff --git a/examples/src/main/scala/sttp/tapir/examples/errors/errorAsJson.scala b/examples/src/main/scala/sttp/tapir/examples/errors/errorAsJson.scala new file mode 100644 index 0000000000..953f4e10cf --- /dev/null +++ b/examples/src/main/scala/sttp/tapir/examples/errors/errorAsJson.scala @@ -0,0 +1,87 @@ +// {cat=Error handling; effects=Future; server=Pekko HTTP; json=circe}: Error and successful outputs + +//> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.12 +//> using dep com.softwaremill.sttp.tapir::tapir-pekko-http-server:1.11.12 +//> using dep com.softwaremill.sttp.tapir::tapir-json-circe:1.11.12 +//> using dep org.apache.pekko::pekko-http:1.0.1 +//> using dep org.apache.pekko::pekko-stream:1.0.3 +//> using dep com.softwaremill.sttp.client3::core:3.10.2 +//> using dep com.softwaremill.sttp.client3::circe:3.10.2" + +package sttp.tapir.examples.errors + +import io.circe.generic.auto.* +import io.circe.parser +import org.apache.pekko.actor.ActorSystem +import org.apache.pekko.http.scaladsl.Http +import org.apache.pekko.http.scaladsl.server.Route +import sttp.client3.* +import sttp.client3.circe.* +import sttp.model.StatusCode +import sttp.shared.Identity +import sttp.tapir.* +import sttp.tapir.generic.auto.* +import sttp.tapir.json.circe.* +import sttp.tapir.server.model.ValuedEndpointOutput +import sttp.tapir.server.pekkohttp.* + +import scala.concurrent.duration.* +import scala.concurrent.{Await, Future} + +@main def errorAsJson(): Unit = + implicit val actorSystem: ActorSystem = ActorSystem() + import actorSystem.dispatcher + + // the endpoint description + enum Severity: + case Trace, Debug, Info, Warning, Error, Fatal + + case class Error(severity: Severity, message: String) + + case class Person(name: String, surname: String, age: Int) + + val errorJson: PublicEndpoint[Person, Unit, String, Any] = + endpoint.post + .in("person" / jsonBody[Person]) + .out(stringBody) + + // By default, convert all String errors to Error case class with severity "Error" + val options = PekkoHttpServerOptions.customiseInterceptors.defaultHandlers(err => ValuedEndpointOutput(jsonBody[Error], Error(Severity.Error, err))).options + + // converting an endpoint to a route + val errorOrJsonRoute: Route = PekkoHttpServerInterpreter(options).toRoute(errorJson.serverLogic { + case person if person.age < 18 => throw new RuntimeException("Oops, something went wrong in the server internals!") + case x => Future.successful(Right("Operation successful")) + }) + + // starting the server + val bindAndCheck = Http().newServerAt("localhost", 8080).bindFlow(errorOrJsonRoute).map { binding => + // testing + val backend: SttpBackend[Identity, Any] = HttpURLConnectionBackend() + + // This causes internal exception, resulting in response status code 500 + val response1 = basicRequest.post(uri"http://localhost:8080/person").body(Person("Pawel", "Stawicki", 4)).send(backend) + assert(response1.code == StatusCode.InternalServerError) + val result1: Either[String, String] = response1.body + println("Got result (1): " + result1) + // Response body contains Error case class serialized to JSON + // Mind the "swap" - this Either was originally Left, but we need to swap it in order to parse it later + val error1 = result1.swap.flatMap(parser.parse).flatMap(_.as[Error]) + assert(error1 == Right(Error(Severity.Error, "Internal server error"))) + + // Bad request sent, resulting in response status code 400 + val response2 = basicRequest.post(uri"http://localhost:8080/person").body("invalid json").send(backend) + assert(response2.code == StatusCode.BadRequest) + val result2: Either[String, String] = response2.body + println("Got result (2): " + result2) + val error2 = result2.swap.flatMap(parser.parse).flatMap(_.as[Error]) + assert(error2 == Right(Error(Severity.Error, "Invalid value for: body (expected json value got 'invali...' (line 1, column 1))"))) + + val result3: Either[String, String] = basicRequest.post(uri"http://localhost:8080/person").body(Person("Pawel", "Stawicki", 46)).send(backend).body + println("Got result (3): " + result3) + assert(result3 == Right("Operation successful")) + + binding + } + + val _ = Await.result(bindAndCheck.flatMap(_.terminate(1.minute)), 1.minute) From c862d9e8be34ed2cca4112dcc8e24bfbedc4b483 Mon Sep 17 00:00:00 2001 From: Pawel Stawicki Date: Wed, 15 Jan 2025 09:44:12 +0100 Subject: [PATCH 3/3] Fixed type, some comments too --- .../scala/sttp/tapir/examples/errors/errorAsJson.scala | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/src/main/scala/sttp/tapir/examples/errors/errorAsJson.scala b/examples/src/main/scala/sttp/tapir/examples/errors/errorAsJson.scala index 953f4e10cf..2a84cce5a3 100644 --- a/examples/src/main/scala/sttp/tapir/examples/errors/errorAsJson.scala +++ b/examples/src/main/scala/sttp/tapir/examples/errors/errorAsJson.scala @@ -1,4 +1,4 @@ -// {cat=Error handling; effects=Future; server=Pekko HTTP; json=circe}: Error and successful outputs +// {cat=Error handling; effects=Future; server=Pekko HTTP; json=circe}: Default error handler returning errors as JSON //> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.12 //> using dep com.softwaremill.sttp.tapir::tapir-pekko-http-server:1.11.12 @@ -6,7 +6,7 @@ //> using dep org.apache.pekko::pekko-http:1.0.1 //> using dep org.apache.pekko::pekko-stream:1.0.3 //> using dep com.softwaremill.sttp.client3::core:3.10.2 -//> using dep com.softwaremill.sttp.client3::circe:3.10.2" +//> using dep com.softwaremill.sttp.client3::circe:3.10.2 package sttp.tapir.examples.errors @@ -32,14 +32,14 @@ import scala.concurrent.{Await, Future} implicit val actorSystem: ActorSystem = ActorSystem() import actorSystem.dispatcher - // the endpoint description enum Severity: case Trace, Debug, Info, Warning, Error, Fatal - + case class Error(severity: Severity, message: String) case class Person(name: String, surname: String, age: Int) + // the endpoint description val errorJson: PublicEndpoint[Person, Unit, String, Any] = endpoint.post .in("person" / jsonBody[Person])