From 746ad5fed41a536916652c42c1c8810602119842 Mon Sep 17 00:00:00 2001 From: Pawel Stawicki Date: Fri, 10 Jan 2025 15:09:38 +0100 Subject: [PATCH 1/5] Example: respond with 404 if None returned from server logic --- .../sttp/tapir/examples/booksExample.scala | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/examples/src/main/scala/sttp/tapir/examples/booksExample.scala b/examples/src/main/scala/sttp/tapir/examples/booksExample.scala index 934e2d8432..9242ed9946 100644 --- a/examples/src/main/scala/sttp/tapir/examples/booksExample.scala +++ b/examples/src/main/scala/sttp/tapir/examples/booksExample.scala @@ -11,6 +11,7 @@ package sttp.tapir.examples +import sttp.model.StatusCode import sttp.tapir.generic.auto.* @main def booksExample(): Unit = @@ -56,6 +57,16 @@ import sttp.tapir.generic.auto.* val booksListingByGenre: PublicEndpoint[BooksQuery, String, Vector[Book], Any] = baseEndpoint.get .in(("list" / path[String]("genre").map(Option(_))(_.get)).and(limitParameter).mapTo[BooksQuery]) .out(jsonBody[Vector[Book]]) + + // Optional value from serverLogic, responding with 404 when None + val singleBook = baseEndpoint.get + .in("book" / query[String]("title")) + .out(oneOf( + oneOfVariantExactMatcher(StatusCode.NotFound, jsonBody[Option[Book]])(None), + oneOfVariantValueMatcher(StatusCode.Ok, jsonBody[Option[Book]]) { + case Some(book) => true + } + )) end Endpoints // @@ -123,12 +134,18 @@ import sttp.tapir.generic.auto.* Right[String, Vector[Book]](Library.getBooks(query)) } + def singleBookLogic(title: String): Future[Either[String, Option[Book]]] = + Future { + Right(Library.Books.get().find(_.title == title)) + } + // interpreting the endpoint description and converting it to an pekko-http route, providing the logic which // should be run when the endpoint is invoked. List( addBook.serverLogic(bookAddLogic.tupled), booksListing.serverLogic(bookListingLogic), - booksListingByGenre.serverLogic(bookListingByGenreLogic) + booksListingByGenre.serverLogic(bookListingByGenreLogic), + singleBook.serverLogic(singleBookLogic) ) end booksServerEndpoints @@ -137,7 +154,7 @@ import sttp.tapir.generic.auto.* // interpreting the endpoint descriptions as yaml openapi documentation // exposing the docs using SwaggerUI endpoints, interpreted as an pekko-http route - SwaggerInterpreter().fromEndpoints(List(addBook, booksListing, booksListingByGenre), "The Tapir Library", "1.0") + SwaggerInterpreter().fromEndpoints(List(addBook, booksListing, booksListingByGenre, singleBook), "The Tapir Library", "1.0") end swaggerUIServerEndpoints def startServer(serverEndpoints: List[ServerEndpoint[Any, Future]]): Unit = From dce2233138ef988e97f2903fcbfb807897ecba17 Mon Sep 17 00:00:00 2001 From: Pawel Stawicki Date: Mon, 13 Jan 2025 14:52:51 +0100 Subject: [PATCH 2/5] Example of optional result from server logic, responding with 404 for None and 200 and body for Some --- .../optionalValueExample.scala | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 examples/src/main/scala/sttp/tapir/examples/optional_content/optionalValueExample.scala diff --git a/examples/src/main/scala/sttp/tapir/examples/optional_content/optionalValueExample.scala b/examples/src/main/scala/sttp/tapir/examples/optional_content/optionalValueExample.scala new file mode 100644 index 0000000000..7b5344b6b3 --- /dev/null +++ b/examples/src/main/scala/sttp/tapir/examples/optional_content/optionalValueExample.scala @@ -0,0 +1,131 @@ +// {cat=Optional value; effects=Future; server=Pekko HTTP; JSON=circe; docs=Swagger UI}: Optional returned from the server logic, resulting in 404 if None + +//> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.11 +//> using dep com.softwaremill.sttp.tapir::tapir-pekko-http-server:1.11.11 +//> using dep com.softwaremill.sttp.tapir::tapir-json-circe:1.11.11 +//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:1.11.11 +//> using dep ch.qos.logback:logback-classic:1.5.6 + +package sttp.tapir.examples.optional_content + +import sttp.model.StatusCode +import sttp.tapir.generic.auto.* + +@main def optionalValueExample(): Unit = + import org.slf4j.{Logger, LoggerFactory} + val logger: Logger = LoggerFactory.getLogger(this.getClass().getName) + + type Limit = Option[Int] + + case class Country(name: String) + case class Author(name: String, country: Country) + case class Genre(name: String, description: String) + case class Book(title: String, genre: Genre, year: Int, author: Author) + + /** Descriptions of endpoints used in the example. */ + object Endpoints: + import io.circe.generic.auto.* + import sttp.tapir.* + import sttp.tapir.json.circe.* + + // All endpoints report errors as strings, and have the common path prefix '/books' + private val baseEndpoint = endpoint.errorOut(stringBody).in("books") + + // Re-usable parameter description + private val limitParameter = query[Option[Int]]("limit").description("Maximum number of books to retrieve") + + val booksListing: PublicEndpoint[Unit, String, Vector[Book], Any] = baseEndpoint.get + .in("list" / "all") + .out(jsonBody[Vector[Book]]) + + // Optional value from serverLogic, responding with 404 when None + val singleBook = baseEndpoint.get + .in("book" / query[String]("title")) + .out(oneOf( + oneOfVariantExactMatcher(StatusCode.NotFound, jsonBody[Option[Book]])(None), + oneOfVariantValueMatcher(StatusCode.Ok, jsonBody[Option[Book]]) { + case Some(book) => true + } + )) + end Endpoints + + // + + object Library: + import java.util.concurrent.atomic.AtomicReference + + val Books = new AtomicReference( + Vector( + Book( + "The Sorrows of Young Werther", + Genre("Novel", "Novel is genre"), + 1774, + Author("Johann Wolfgang von Goethe", Country("Germany")) + ), + Book("Iliad", Genre("Poetry", ""), -8000, Author("Homer", Country("Greece"))), + Book("Nad Niemnem", Genre("Novel", ""), 1888, Author("Eliza Orzeszkowa", Country("Poland"))), + Book("The Colour of Magic", Genre("Fantasy", ""), 1983, Author("Terry Pratchett", Country("United Kingdom"))), + Book("The Art of Computer Programming", Genre("Non-fiction", ""), 1968, Author("Donald Knuth", Country("USA"))), + Book("Pharaoh", Genre("Novel", ""), 1897, Author("Boleslaw Prus", Country("Poland"))) + ) + ) + end Library + + // + + import Endpoints.* + import sttp.tapir.server.ServerEndpoint + import scala.concurrent.Future + + def booksServerEndpoints: List[ServerEndpoint[Any, Future]] = + import scala.concurrent.ExecutionContext.Implicits.global + + def bookListingLogic(): Future[Either[String, Vector[Book]]] = + Future { + Right[String, Vector[Book]](Library.Books.get()) + } + + def singleBookLogic(title: String): Future[Either[String, Option[Book]]] = + Future { + Right(Library.Books.get().find(_.title == title)) + } + + // interpreting the endpoint description and converting it to an pekko-http route, providing the logic which + // should be run when the endpoint is invoked. + List( + booksListing.serverLogic(_ => bookListingLogic()), + singleBook.serverLogic(singleBookLogic) + ) + end booksServerEndpoints + + def swaggerUIServerEndpoints: List[ServerEndpoint[Any, Future]] = + import sttp.tapir.swagger.bundle.SwaggerInterpreter + + // interpreting the endpoint descriptions as yaml openapi documentation + // exposing the docs using SwaggerUI endpoints, interpreted as an pekko-http route + SwaggerInterpreter().fromEndpoints(List(booksListing, singleBook), "The Tapir Library", "1.0") + end swaggerUIServerEndpoints + + def startServer(serverEndpoints: List[ServerEndpoint[Any, Future]]): Unit = + import org.apache.pekko.actor.ActorSystem + import org.apache.pekko.http.scaladsl.Http + + import scala.concurrent.Await + import scala.concurrent.duration.* + + import sttp.tapir.server.pekkohttp.PekkoHttpServerInterpreter + + implicit val actorSystem: ActorSystem = ActorSystem() + import actorSystem.dispatcher + val routes = PekkoHttpServerInterpreter().toRoute(serverEndpoints) + Await.result(Http().newServerAt("localhost", 8080).bindFlow(routes), 1.minute) + + logger.info("Server started") + end startServer + + logger.info("Welcome to the Tapir Library example!") + + logger.info("Starting the server ...") + startServer(booksServerEndpoints ++ swaggerUIServerEndpoints) + + logger.info("Try out the API by opening the Swagger UI: http://localhost:8080/docs") From 3700a165b4c8a3eb0e6e2fe019810c08656bd68d Mon Sep 17 00:00:00 2001 From: Pawel Stawicki Date: Mon, 13 Jan 2025 15:06:02 +0100 Subject: [PATCH 3/5] Revert "Example: respond with 404 if None returned from server logic" This reverts commit 746ad5fed41a536916652c42c1c8810602119842. --- .../sttp/tapir/examples/booksExample.scala | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/examples/src/main/scala/sttp/tapir/examples/booksExample.scala b/examples/src/main/scala/sttp/tapir/examples/booksExample.scala index 9242ed9946..934e2d8432 100644 --- a/examples/src/main/scala/sttp/tapir/examples/booksExample.scala +++ b/examples/src/main/scala/sttp/tapir/examples/booksExample.scala @@ -11,7 +11,6 @@ package sttp.tapir.examples -import sttp.model.StatusCode import sttp.tapir.generic.auto.* @main def booksExample(): Unit = @@ -57,16 +56,6 @@ import sttp.tapir.generic.auto.* val booksListingByGenre: PublicEndpoint[BooksQuery, String, Vector[Book], Any] = baseEndpoint.get .in(("list" / path[String]("genre").map(Option(_))(_.get)).and(limitParameter).mapTo[BooksQuery]) .out(jsonBody[Vector[Book]]) - - // Optional value from serverLogic, responding with 404 when None - val singleBook = baseEndpoint.get - .in("book" / query[String]("title")) - .out(oneOf( - oneOfVariantExactMatcher(StatusCode.NotFound, jsonBody[Option[Book]])(None), - oneOfVariantValueMatcher(StatusCode.Ok, jsonBody[Option[Book]]) { - case Some(book) => true - } - )) end Endpoints // @@ -134,18 +123,12 @@ import sttp.tapir.generic.auto.* Right[String, Vector[Book]](Library.getBooks(query)) } - def singleBookLogic(title: String): Future[Either[String, Option[Book]]] = - Future { - Right(Library.Books.get().find(_.title == title)) - } - // interpreting the endpoint description and converting it to an pekko-http route, providing the logic which // should be run when the endpoint is invoked. List( addBook.serverLogic(bookAddLogic.tupled), booksListing.serverLogic(bookListingLogic), - booksListingByGenre.serverLogic(bookListingByGenreLogic), - singleBook.serverLogic(singleBookLogic) + booksListingByGenre.serverLogic(bookListingByGenreLogic) ) end booksServerEndpoints @@ -154,7 +137,7 @@ import sttp.tapir.generic.auto.* // interpreting the endpoint descriptions as yaml openapi documentation // exposing the docs using SwaggerUI endpoints, interpreted as an pekko-http route - SwaggerInterpreter().fromEndpoints(List(addBook, booksListing, booksListingByGenre, singleBook), "The Tapir Library", "1.0") + SwaggerInterpreter().fromEndpoints(List(addBook, booksListing, booksListingByGenre), "The Tapir Library", "1.0") end swaggerUIServerEndpoints def startServer(serverEndpoints: List[ServerEndpoint[Any, Future]]): Unit = From ee99d8a0f4d70cb830a2a4ec0280dcf9b34efe27 Mon Sep 17 00:00:00 2001 From: Pawel Stawicki Date: Tue, 14 Jan 2025 17:10:09 +0100 Subject: [PATCH 4/5] Much code removed, example category changed to "Error handling" --- .../errors/optionalValueExample.scala | 65 +++++++++ .../optionalValueExample.scala | 131 ------------------ 2 files changed, 65 insertions(+), 131 deletions(-) create mode 100644 examples/src/main/scala/sttp/tapir/examples/errors/optionalValueExample.scala delete mode 100644 examples/src/main/scala/sttp/tapir/examples/optional_content/optionalValueExample.scala diff --git a/examples/src/main/scala/sttp/tapir/examples/errors/optionalValueExample.scala b/examples/src/main/scala/sttp/tapir/examples/errors/optionalValueExample.scala new file mode 100644 index 0000000000..4a41bb3467 --- /dev/null +++ b/examples/src/main/scala/sttp/tapir/examples/errors/optionalValueExample.scala @@ -0,0 +1,65 @@ +// {cat=Error handling; effects=Future; server=Pekko HTTP; JSON=circe; docs=Swagger UI}: Optional returned from the server logic, resulting in 404 if None + +//> 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 com.softwaremill.sttp.client3::core:3.10.2 + +package sttp.tapir.examples.errors + +import io.circe.generic.auto.* +import io.circe.parser.parse +import org.apache.pekko.actor.ActorSystem +import org.apache.pekko.http.scaladsl.Http +import sttp.client3.{HttpURLConnectionBackend, Identity, SttpBackend, UriContext, basicRequest} +import sttp.model.StatusCode +import sttp.tapir.* +import sttp.tapir.generic.auto.* +import sttp.tapir.json.circe.* +import sttp.tapir.server.pekkohttp.PekkoHttpServerInterpreter + +import scala.concurrent.{Await, Future} +import scala.concurrent.duration.* + +@main def optionalValueExample(): Unit = + + case class Beer(name: String, volumeInLiters: Double) + + val bartenderEndpoint = endpoint + .get + .in("beer" / query[Int]("age")) + // Optional value from serverLogic, responding with 404 "Not Found" when logic returns None + .out(oneOf( + oneOfVariantExactMatcher(StatusCode.NotFound, jsonBody[Option[Beer]])(None), + oneOfVariantValueMatcher(StatusCode.Ok, jsonBody[Option[Beer]]) { + case Some(book) => true + } + )) + + // + + val bartenderServerEndpoint = bartenderEndpoint.serverLogic { + case a if a < 18 => Future.successful(Right(None)) + case _ => Future.successful(Right(Some(Beer("IPA", 0.5)))) + } + + + implicit val actorSystem: ActorSystem = ActorSystem() + import actorSystem.dispatcher + val routes = PekkoHttpServerInterpreter().toRoute(bartenderServerEndpoint) + + val serverBinding = Http().newServerAt("localhost", 8080).bindFlow(routes).map { binding => + val backend: SttpBackend[Identity, Any] = HttpURLConnectionBackend() + + val response1 = basicRequest.get(uri"http://localhost:8080/beer?age=15").send(backend) + assert(response1.code == StatusCode.NotFound) + + val response2 = basicRequest.get(uri"http://localhost:8080/beer?age=21").send(backend) + println("Got result: " + response2.body) + val beerEither = response2.body.flatMap(parse).flatMap(_.as[Beer]) + assert(beerEither == Right(Beer("IPA", 0.5))) + + binding + } + + val _ = Await.result(serverBinding, 1.minute) diff --git a/examples/src/main/scala/sttp/tapir/examples/optional_content/optionalValueExample.scala b/examples/src/main/scala/sttp/tapir/examples/optional_content/optionalValueExample.scala deleted file mode 100644 index 7b5344b6b3..0000000000 --- a/examples/src/main/scala/sttp/tapir/examples/optional_content/optionalValueExample.scala +++ /dev/null @@ -1,131 +0,0 @@ -// {cat=Optional value; effects=Future; server=Pekko HTTP; JSON=circe; docs=Swagger UI}: Optional returned from the server logic, resulting in 404 if None - -//> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.11 -//> using dep com.softwaremill.sttp.tapir::tapir-pekko-http-server:1.11.11 -//> using dep com.softwaremill.sttp.tapir::tapir-json-circe:1.11.11 -//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:1.11.11 -//> using dep ch.qos.logback:logback-classic:1.5.6 - -package sttp.tapir.examples.optional_content - -import sttp.model.StatusCode -import sttp.tapir.generic.auto.* - -@main def optionalValueExample(): Unit = - import org.slf4j.{Logger, LoggerFactory} - val logger: Logger = LoggerFactory.getLogger(this.getClass().getName) - - type Limit = Option[Int] - - case class Country(name: String) - case class Author(name: String, country: Country) - case class Genre(name: String, description: String) - case class Book(title: String, genre: Genre, year: Int, author: Author) - - /** Descriptions of endpoints used in the example. */ - object Endpoints: - import io.circe.generic.auto.* - import sttp.tapir.* - import sttp.tapir.json.circe.* - - // All endpoints report errors as strings, and have the common path prefix '/books' - private val baseEndpoint = endpoint.errorOut(stringBody).in("books") - - // Re-usable parameter description - private val limitParameter = query[Option[Int]]("limit").description("Maximum number of books to retrieve") - - val booksListing: PublicEndpoint[Unit, String, Vector[Book], Any] = baseEndpoint.get - .in("list" / "all") - .out(jsonBody[Vector[Book]]) - - // Optional value from serverLogic, responding with 404 when None - val singleBook = baseEndpoint.get - .in("book" / query[String]("title")) - .out(oneOf( - oneOfVariantExactMatcher(StatusCode.NotFound, jsonBody[Option[Book]])(None), - oneOfVariantValueMatcher(StatusCode.Ok, jsonBody[Option[Book]]) { - case Some(book) => true - } - )) - end Endpoints - - // - - object Library: - import java.util.concurrent.atomic.AtomicReference - - val Books = new AtomicReference( - Vector( - Book( - "The Sorrows of Young Werther", - Genre("Novel", "Novel is genre"), - 1774, - Author("Johann Wolfgang von Goethe", Country("Germany")) - ), - Book("Iliad", Genre("Poetry", ""), -8000, Author("Homer", Country("Greece"))), - Book("Nad Niemnem", Genre("Novel", ""), 1888, Author("Eliza Orzeszkowa", Country("Poland"))), - Book("The Colour of Magic", Genre("Fantasy", ""), 1983, Author("Terry Pratchett", Country("United Kingdom"))), - Book("The Art of Computer Programming", Genre("Non-fiction", ""), 1968, Author("Donald Knuth", Country("USA"))), - Book("Pharaoh", Genre("Novel", ""), 1897, Author("Boleslaw Prus", Country("Poland"))) - ) - ) - end Library - - // - - import Endpoints.* - import sttp.tapir.server.ServerEndpoint - import scala.concurrent.Future - - def booksServerEndpoints: List[ServerEndpoint[Any, Future]] = - import scala.concurrent.ExecutionContext.Implicits.global - - def bookListingLogic(): Future[Either[String, Vector[Book]]] = - Future { - Right[String, Vector[Book]](Library.Books.get()) - } - - def singleBookLogic(title: String): Future[Either[String, Option[Book]]] = - Future { - Right(Library.Books.get().find(_.title == title)) - } - - // interpreting the endpoint description and converting it to an pekko-http route, providing the logic which - // should be run when the endpoint is invoked. - List( - booksListing.serverLogic(_ => bookListingLogic()), - singleBook.serverLogic(singleBookLogic) - ) - end booksServerEndpoints - - def swaggerUIServerEndpoints: List[ServerEndpoint[Any, Future]] = - import sttp.tapir.swagger.bundle.SwaggerInterpreter - - // interpreting the endpoint descriptions as yaml openapi documentation - // exposing the docs using SwaggerUI endpoints, interpreted as an pekko-http route - SwaggerInterpreter().fromEndpoints(List(booksListing, singleBook), "The Tapir Library", "1.0") - end swaggerUIServerEndpoints - - def startServer(serverEndpoints: List[ServerEndpoint[Any, Future]]): Unit = - import org.apache.pekko.actor.ActorSystem - import org.apache.pekko.http.scaladsl.Http - - import scala.concurrent.Await - import scala.concurrent.duration.* - - import sttp.tapir.server.pekkohttp.PekkoHttpServerInterpreter - - implicit val actorSystem: ActorSystem = ActorSystem() - import actorSystem.dispatcher - val routes = PekkoHttpServerInterpreter().toRoute(serverEndpoints) - Await.result(Http().newServerAt("localhost", 8080).bindFlow(routes), 1.minute) - - logger.info("Server started") - end startServer - - logger.info("Welcome to the Tapir Library example!") - - logger.info("Starting the server ...") - startServer(booksServerEndpoints ++ swaggerUIServerEndpoints) - - logger.info("Try out the API by opening the Swagger UI: http://localhost:8080/docs") From 43eb74422fff1a07915a536584ed824743f49b15 Mon Sep 17 00:00:00 2001 From: Pawel Stawicki Date: Wed, 15 Jan 2025 09:32:09 +0100 Subject: [PATCH 5/5] Removed "Swagger" from docs, `book` where the name was not used at all and changed "implicit" to "given" as we are at Scala 3 --- .../sttp/tapir/examples/errors/optionalValueExample.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/src/main/scala/sttp/tapir/examples/errors/optionalValueExample.scala b/examples/src/main/scala/sttp/tapir/examples/errors/optionalValueExample.scala index 4a41bb3467..82455c4f18 100644 --- a/examples/src/main/scala/sttp/tapir/examples/errors/optionalValueExample.scala +++ b/examples/src/main/scala/sttp/tapir/examples/errors/optionalValueExample.scala @@ -1,4 +1,4 @@ -// {cat=Error handling; effects=Future; server=Pekko HTTP; JSON=circe; docs=Swagger UI}: Optional returned from the server logic, resulting in 404 if None +// {cat=Error handling; effects=Future; server=Pekko HTTP; JSON=circe}: Optional returned from the server logic, resulting in 404 if None //> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.12 //> using dep com.softwaremill.sttp.tapir::tapir-pekko-http-server:1.11.12 @@ -32,7 +32,7 @@ import scala.concurrent.duration.* .out(oneOf( oneOfVariantExactMatcher(StatusCode.NotFound, jsonBody[Option[Beer]])(None), oneOfVariantValueMatcher(StatusCode.Ok, jsonBody[Option[Beer]]) { - case Some(book) => true + case Some(_) => true } )) @@ -44,7 +44,7 @@ import scala.concurrent.duration.* } - implicit val actorSystem: ActorSystem = ActorSystem() + given actorSystem: ActorSystem = ActorSystem() import actorSystem.dispatcher val routes = PekkoHttpServerInterpreter().toRoute(bartenderServerEndpoint)