Skip to content

Commit

Permalink
Merge branch 'master' into feat/add-zio-multipart-body-support
Browse files Browse the repository at this point in the history
  • Loading branch information
seakayone authored Jun 25, 2024
2 parents 498afa4 + b3e2570 commit fa9c049
Show file tree
Hide file tree
Showing 62 changed files with 763 additions and 147 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ metals.sbt
template.yaml
aws-lambda-cats-effect-template.yaml
aws-lambda-zio-template.yaml
.scala-build

.bsp
.idea*
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ tapir documentation is available at [tapir.softwaremill.com](http://tapir.softwa
Add the following dependency:

```sbt
"com.softwaremill.sttp.tapir" %% "tapir-core" % "1.10.9"
"com.softwaremill.sttp.tapir" %% "tapir-core" % "1.10.10"
```

Then, import:
Expand Down
8 changes: 4 additions & 4 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -505,15 +505,15 @@ lazy val perfTests: ProjectMatrix = (projectMatrix in file("perf-tests"))
name := "tapir-perf-tests",
libraryDependencies ++= Seq(
// Required to force newer jackson in Pekko, a version that is compatible with Gatling's Jackson dependency
"io.gatling.highcharts" % "gatling-charts-highcharts" % "3.11.3" % "test" exclude (
"io.gatling.highcharts" % "gatling-charts-highcharts" % "3.11.4" % "test" exclude (
"com.fasterxml.jackson.core",
"jackson-databind"
),
"io.gatling" % "gatling-test-framework" % "3.11.3" % "test" exclude ("com.fasterxml.jackson.core", "jackson-databind"),
"io.gatling" % "gatling-test-framework" % "3.11.4" % "test" exclude ("com.fasterxml.jackson.core", "jackson-databind"),
"com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.17.1",
"nl.grons" %% "metrics4-scala" % Versions.metrics4Scala % Test,
"com.lihaoyi" %% "scalatags" % Versions.scalaTags % Test,
"io.github.classgraph" % "classgraph" % "4.8.173",
"io.github.classgraph" % "classgraph" % "4.8.174",
"org.http4s" %% "http4s-core" % Versions.http4s,
"org.http4s" %% "http4s-dsl" % Versions.http4s,
"org.http4s" %% "http4s-blaze-server" % Versions.http4sBlazeServer,
Expand Down Expand Up @@ -1765,7 +1765,7 @@ lazy val awsTerraform: ProjectMatrix = (projectMatrix in file("serverless/aws/te
"io.circe" %% "circe-yaml" % Versions.circeYaml,
"io.circe" %% "circe-generic" % Versions.circe,
"io.circe" %% "circe-literal" % Versions.circe,
"org.typelevel" %% "jawn-parser" % "1.5.1"
"org.typelevel" %% "jawn-parser" % "1.6.0"
)
)
.jvmPlatform(scalaVersions = scala2Versions)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ private[tapir] object SchemaMapMacro {

val genericTypeParameters =
(if (keyTypeParameter.split('.').lastOption.contains("String")) Nil else List(keyTypeParameter)) ++
extractTypeArguments(weakTypeK) ++ List(weakTypeV.typeSymbol.fullName) ++ extractTypeArguments(weakTypeV)
extractTypeArguments(weakTypeK.dealias) ++ List(weakTypeV.typeSymbol.fullName) ++ extractTypeArguments(weakTypeV.dealias)
val schemaForMap =
q"""{
val s = $schemaForV
Expand Down
5 changes: 4 additions & 1 deletion core/src/main/scala/sttp/tapir/Schema.scala
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,10 @@ object Schema extends LowPrioritySchema with SchemaCompanionMacros {
for {
na <- sa.name
nb <- sb.name
} yield Schema.SName("Either", List(na.show, nb.show))
} yield Schema.SName(
"Either",
(na.fullName :: na.typeParameterShortNames) ++ (nb.fullName :: nb.typeParameterShortNames)
)
)
}

Expand Down
43 changes: 43 additions & 0 deletions core/src/test/scala/sttp/tapir/SchemaTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,47 @@ class SchemaTest extends AnyFlatSpec with Matchers {
implicitly[Schema[Option[Double]]].format shouldBe Some("double")
}

case class SomeValueString[A](value: String, v2: A)
final case class SomeValueInt(value: Int)
final case class Node[A](values: List[A])

it should "generate correct names for Eithers with parameterized types" in {
import sttp.tapir.generic.auto._
implicitly[Schema[Either[Int, Int]]].name shouldBe None
implicitly[Schema[Either[SomeValueInt, Int]]].name shouldBe None
implicitly[Schema[Either[SomeValueInt, SomeValueInt]]].name shouldBe Some(
SName("Either", List("sttp.tapir.SchemaTest.SomeValueInt", "sttp.tapir.SchemaTest.SomeValueInt"))
)
implicitly[Schema[Either[SomeValueInt, Node[SomeValueString[Boolean]]]]].name shouldBe Some(
SName("Either", List("sttp.tapir.SchemaTest.SomeValueInt", "sttp.tapir.SchemaTest.Node", "sttp.tapir.SchemaTest.SomeValueString", "scala.Boolean"))
)
implicitly[Schema[Either[SomeValueInt, Node[String]]]].name shouldBe Some(
SName("Either", List("sttp.tapir.SchemaTest.SomeValueInt", "sttp.tapir.SchemaTest.Node", "java.lang.String"))
)
implicitly[Schema[Either[Node[Boolean], SomeValueInt]]].name shouldBe Some(
SName("Either", List("sttp.tapir.SchemaTest.Node", "scala.Boolean", "sttp.tapir.SchemaTest.SomeValueInt"))
)
}

it should "generate correct names for Maps with parameterized types" in {
import sttp.tapir.generic.auto._
type Tree[A] = Either[A, Node[A]]
val schema1: Schema[Map[SomeValueInt, Node[SomeValueString[Boolean]]]] = Schema.schemaForMap(_.toString)
schema1.name shouldBe Some(
SName("Map", List("sttp.tapir.SchemaTest.SomeValueInt", "sttp.tapir.SchemaTest.Node", "sttp.tapir.SchemaTest.SomeValueString", "scala.Boolean"))
)
val schema2: Schema[Map[Node[Boolean], Node[String]]] = Schema.schemaForMap(_.toString)
schema2.name shouldBe Some(
SName("Map", List("sttp.tapir.SchemaTest.Node", "scala.Boolean", "sttp.tapir.SchemaTest.Node", "java.lang.String"))
)
val schema3: Schema[Map[Int, Tree[String]]] = Schema.schemaForMap(_.toString)
schema3.name shouldBe Some(
SName("Map", List("scala.Int", "scala.util.Either", "java.lang.String", "sttp.tapir.SchemaTest.Node", "java.lang.String"))
)
val schema4: Schema[Map[Tree[String], Int]] = Schema.schemaForMap(_.toString)
schema4.name shouldBe Some(
SName("Map", List("scala.util.Either", "java.lang.String", "sttp.tapir.SchemaTest.Node", "java.lang.String", "scala.Int"))
)
}

}
1 change: 1 addition & 0 deletions doc/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ We offer commercial support for sttp and related technologies, as well as develo
tutorials/01_hello_world
tutorials/02_openapi_docs
tutorials/03_json
tutorials/04_errors
.. toctree::
:maxdepth: 2
Expand Down
2 changes: 1 addition & 1 deletion doc/server/http4s.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The http4s interpreter accepts streaming bodies of type `Stream[F, Byte]`, as de
capability. Both response bodies and request bodies can be streamed. Usage: `streamBody(Fs2Streams[F])(schema, format)`.

The capability can be added to the classpath independently of the interpreter through the
`"com.softwaremill.sttp.shared" %% "http4s"` dependency.
`"com.softwaremill.sttp.shared" %% "fs2"` [dependency](https://mvnrepository.com/artifact/com.softwaremill.sttp.shared/fs2).

## Http4s backends

Expand Down
6 changes: 5 additions & 1 deletion doc/tutorials/03_json.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# 3. Using JSON bodies

```{note}
The tutorial is also available [as a video](https://www.youtube.com/watch?v=NG8XWS7ijHU).
```

The endpoints we defined in the previous tutorials all used `String` bodies. Quite naturally, tapir supports
much more than that - using appropriate **codecs**, it's possible to serialize and deserialize to arbitrary types.
The most popular format on the web is JSON; hence, let's see how to expose a JSON-based endpoint using tapir.
Expand All @@ -20,7 +24,7 @@ we'll see how in a moment:
```

Once we have that, let's define our data model, which we'll use for requests and responses. We'll define a single
endpoint, transforming a `Meal` class into a `Nutrition` one:
endpoint, transforming a `Meal` instance into a `Nutrition` one:

{emphasize-lines="3-4"}
```scala
Expand Down
220 changes: 220 additions & 0 deletions doc/tutorials/04_errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
# 4. Error handling

Many things can go wrong: that's why error handling is often the centerpiece of software libraries. We got a glimpse of
one of Tapir's components when it comes to error handling when we discussed
[adding OpenAPI documentation](02_openapi_docs.md). In this tutorial, we'll investigate Tapir's approach to error
handling in more detail.

Errors might be divided into "expected" errors: that is ones that we know how to handle, for which we have designed a
specific response. These errors are most often caused by invalid input from the user (that is, invalid data that's part
of the HTTP request). For such requests, we should return responses with error codes between 400 and 499, which are
designated in the HTTP specification as "client errors".

On the other hand, there are "unexpected errors", that we didn't foresee. When they occur, they signal some kind of
problem with the server: a bug in the server's logic, hitting a limit of requests in progress, etc. When this
happens, we should respond with a status code between 500 and 599, and log the error for the developer to inspect. These
are "server errors".

Which error codes exactly are returned, and what's the content of the response body that accompanies them is part of
each endpoint's description and Tapir's configuration.

## Expected errors

As we saw in previous tutorials, the description of an endpoint is a data structure, which contains the inputs (mapped
to HTTP requests) & outputs (mapped to HTTP responses). The outputs of an endpoint describe what should happen on the
"happy path" - when the server logic succeeds. Separately, the endpoint description can contain **error outputs**, which
describe the shape of the HTTP response, in case an "expected error" occurs.

Unless specified otherwise as part of the endpoint's description, when the HTTP response is generated using the
successful outputs, the 200 status code is used; in case of error outputs, the status code is 400.

Let's define an endpoint, which returns the JSON representation of the `Result` data type in case of success, and
the JSON corresponding to the `Error` data type in case of an error. We'll be editing a `errors.scala` file.

As in the previous tutorial, we'll be using Jsoniter to handle serialisation to JSON. We'll also need to derive the
schemas both for the `Result` and `Error` classes, to represent them properly in documentation. Let's start by
describing the endpoint:

```scala
//> using dep com.softwaremill.sttp.tapir::tapir-core:@VERSION@
//> using dep com.softwaremill.sttp.tapir::tapir-jsoniter-scala:@VERSION@
//> using dep com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-macros:2.30.1

import com.github.plokhotnyuk.jsoniter_scala.macros.*

import sttp.tapir.*
import sttp.tapir.json.jsoniter.*

case class Result(v: Int) derives ConfiguredJsonValueCodec, Schema
case class Error(description: String) derives ConfiguredJsonValueCodec, Schema

@main def tapirErrors(): Unit =
val maybeErrorEndpoint = endpoint.get
.in("test")
.in(query[Int]("input"))
.out(jsonBody[Result])
.errorOut(jsonBody[Error])
```

Just as calling `.out` on an endpoint description returns an updated endpoint description, with that output added,
calling `.errorOut` returns a copy of the endpoint description, with an error output added. Each invocation of `.in`,
`.out` and `.errorOut` accumulates inputs/outputs/error outputs.

We can now add the server logic to the endpoint, using the `.handle` method. The result of that logic has to indicate
if the result is a success, or an error. That's why the method which we'll need to provide has to return a value of type
`Either[Error, Result]`. By convention, the left-side of an `Either` represents failure, and right-side success; we
follow that in Tapir.

Because endpoints are fully typed, it's statically checked by the compiler that we provide a server logic with types
matching the endpoint's description; in our case, a function of type `Int => Either[Error, Result]`.

We'll also add code to expose the endpoint as a server, along with its OpenAPI documentation:

{emphasize-lines="2-3, 11-13, 24-28, 30-36"}
```scala
//> using dep com.softwaremill.sttp.tapir::tapir-core:@VERSION@
//> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:@VERSION@
//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:@VERSION@
//> using dep com.softwaremill.sttp.tapir::tapir-jsoniter-scala:@VERSION@
//> using dep com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-macros:2.30.1

import com.github.plokhotnyuk.jsoniter_scala.macros.*

import sttp.tapir.*
import sttp.tapir.json.jsoniter.*
import sttp.tapir.server.netty.sync.NettySyncServer
import sttp.tapir.swagger.bundle.SwaggerInterpreter
import sttp.shared.Identity

case class Result(v: Int) derives ConfiguredJsonValueCodec, Schema
case class Error(description: String) derives ConfiguredJsonValueCodec, Schema

@main def tapirErrors(): Unit =
val maybeErrorEndpoint = endpoint.get
.in("test")
.in(query[Int]("input"))
.out(jsonBody[Result])
.errorOut(jsonBody[Error])
.handle { input =>
if input % 2 == 0
then Right(Result(input/2))
else Left(Error("That's an odd number!"))
}

val swaggerEndpoints = SwaggerInterpreter()
.fromServerEndpoints[Identity](List(maybeErrorEndpoint), "My App", "1.0")

NettySyncServer().port(8080)
.addEndpoint(maybeErrorEndpoint)
.addEndpoints(swaggerEndpoints)
.startAndWait()
```

Let's run a couple of tests, to verify that our app does what we wanted:

```bash
% curl -v "http://localhost:8080/test?input=10"
< HTTP/1.1 200 OK
< server: tapir/1.10.9
< Content-Type: application/json
< content-length: 7
<
{"v":5}

% curl -v "http://localhost:8080/test?input=11"
< HTTP/1.1 400 Bad Request
< server: tapir/1.10.9
< Content-Type: application/json
< content-length: 39
<
{"description":"That's an odd number!"}
```

Works as designed! We get different JSONs and different status codes, depending on the result of the server logic.
Also, take a look at [the docs](http://localhost:8080/docs) - they include both response variants, with 200 and 400
status codes.

## Unexpected errors

Every now and then an exception pops up which we forget to properly handle. In such cases, the HTTP server of course
continues to operate, but returns a 500-family response to the client. That's what happens in Tapir as well. By default,
each server contains an **exception interceptor** which returns a `500 Internal Server Error` response, and logs the
exception.

We'll extend our previous example by an occasional unhandled exception being throw from our server logic. Additionally,
we'll add Logback as a dependency, so that we get proper logging as part of the server's output. When you run the
following, you'll see a lot of `DEBUG`-level logs (which can be turned off using `logback.xml`), but more importantly,
you'll also get `ERROR` logs when unhandled exceptions happen:

{emphasize-lines="6, 26"}
```scala
//> using dep com.softwaremill.sttp.tapir::tapir-core:@VERSION@
//> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:@VERSION@
//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:@VERSION@
//> using dep com.softwaremill.sttp.tapir::tapir-jsoniter-scala:@VERSION@
//> using dep com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-macros:2.30.1
//> using dep ch.qos.logback:logback-classic:1.5.6

import com.github.plokhotnyuk.jsoniter_scala.macros.*

import sttp.tapir.*
import sttp.tapir.json.jsoniter.*
import sttp.tapir.server.netty.sync.NettySyncServer
import sttp.tapir.swagger.bundle.SwaggerInterpreter
import sttp.shared.Identity

case class Result(v: Int) derives ConfiguredJsonValueCodec, Schema
case class Error(description: String) derives ConfiguredJsonValueCodec, Schema

@main def tapirErrors(): Unit =
val maybeErrorEndpoint = endpoint.get
.in("test")
.in(query[Int]("input"))
.out(jsonBody[Result])
.errorOut(jsonBody[Error])
.handle { input =>
if input % 3 == 0 then throw new RuntimeException("Multiplies of 3 are unacceptable!")

if input % 2 == 0
then Right(Result(input/2))
else Left(Error("That's an odd number!"))
}

val swaggerEndpoints = SwaggerInterpreter()
.fromServerEndpoints[Identity](List(maybeErrorEndpoint), "My App", "1.0")

NettySyncServer().port(8080)
.addEndpoint(maybeErrorEndpoint)
.addEndpoints(swaggerEndpoints)
.startAndWait()
```

Trying to invoke the endpoint results in a 500 status code:

```bash
% curl -v "http://localhost:8080/test?input=9"

< HTTP/1.1 500 Internal Server Error
< server: tapir/1.10.9
< Content-Type: text/plain; charset=UTF-8
< content-length: 21
<
Internal server error
```

And in the logs, we get the full details on what went wrong:

```
16:18:14.355 [virtual-41] ERROR sttp.tapir.server.netty.sync.NettySyncServerOptions$ -- Exception when handling request: GET /test?input=9, by: GET /test, took: 18ms
java.lang.RuntimeException: Multiplies of 3 are unacceptable!
at errors$package$.$anonfun$15(errors.scala:26)
```

## Further reading

There's still a lot to cover on error handling in Tapir, and we'll go into more detail on some of the options in
subsequent tutorials. For the impatient, you might be interested in the following reference documentation sections:

* [error handling in server interpreters](../server/errors.md)
* [one-of outputs](../endpoint/oneof.md)
* [inputs/outputs, section on status codes](../endpoint/ios.md)
Loading

0 comments on commit fa9c049

Please sign in to comment.