Skip to content

Commit

Permalink
Tutorial 6
Browse files Browse the repository at this point in the history
  • Loading branch information
adamw committed Jul 3, 2024
1 parent 71be512 commit bb939b3
Show file tree
Hide file tree
Showing 2 changed files with 297 additions and 0 deletions.
1 change: 1 addition & 0 deletions doc/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ We offer commercial support for sttp and related technologies, as well as develo
tutorials/03_json
tutorials/04_errors
tutorials/05_multiple_inputs_outputs
tutorials/06_error_variants
.. toctree::
:maxdepth: 2
Expand Down
296 changes: 296 additions & 0 deletions doc/tutorials/06_error_variants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
# 6. Error variants

Quite often, there's more than one thing that might go wrong. On the other hand, success can also have many facets.

In the previous tutorials we've seen that Tapir includes built-in support for differentiating between successful and
error outputs. That's because in most cases the response that is returned in case of an error is totally different
from a response returned in case of success.

Hence, Tapir has built-in, top-level response variants: either error, or success. It's also possible to introduce
more response variants, on lower levels, which further differentiate error and success scenarios.

## `oneOf` outputs

Such differentiation of both success and error output can be achieved using `oneOf` output descriptions. As the name
suggests, such outputs describe responses, which can take the shape of one of the given variants. Each variant is a
description of an output, such as the ones that we've seen so far.

We've also seen that each output describes a mapping between a high-level Scala type and the HTTP response.
The same is true for `oneOf` outputs. Because `oneOf` has variants, we need a high-level type which also has variants.
Each variant of the Scala type will correspond to one output variant.

```{note}
For error and successful outputs we also have variants in the high-level type, `Either[E, O]`. There are two variants:
`Left` and `Right`, corresponding to error and success outputs.
```

To represent various output variants on the Scala-value level, we'll typically use an `enum`. Each enum has a number of
variants: exactly what we need. Mind that using an enum is not required when using `oneOf` outputs, just convenient.

## High-level response representation

Let's start coding! We'll try to describe an endpoint, which fetches the avatar of the user. Here's a list of things
that might go wrong:

* unauthorized, in case the avatar of the requested user is not public
* not found, in case there's no user with the provided id
* other, in case the server logic would like to respond with a generic error

And there's also a "list of things that might go right", meaning success variants:

* found, with an array of bytes, containing the avatar
* redirect, with an address where the avatar is located

We'll represent both of these as an enum. We'll be editing the `variants.scala` file:

```scala
enum AvatarError:
case Unauthorized
case NotFound
case Other(msg: String)

enum AvatarSuccess:
case Found(bytes: Array[Byte])
case Redirect(location: String)
```

## An output for a single variant

Now that we have the high-level model in place, let's describe an output for a single variant; `AvatarSuccess.Redirect`
is the most complicated one (we won't be using `oneOf` just yet!).

In case of a redirect, we want the response to contain:

* the `307 Temporary Redirect` status code
* the `Location` header, with a value pointing to the avatar's location

Endpoint outputs are described as instances of the `EndpointOutput` type. We've already seen output descriptions in
previous tutorials; `stringBody` is an `EndpointOutput[String]`, and `jsonBody[Nutrition]` is an
`EndpointOutput[Nutrition]`. Similarly, here, our goal is to obtain a value of type
`EndpointOutput[AvatarSuccess.Redirect]`, which will be mapped to the status code & header described above.

For the status code, we can use the constant status code output: `statusCode(StatusCode.TemporaryRedirect)`. It takes a
`StatusCode` instance from the `sttp.model` package, and has the type `EndpointOutput[Unit]`. The `Unit` means that
it doesn't map any high-level values to the response: it's a constant, and it always describes the same 307 status code.

For the header, we have the `header[String](HeaderNames.Location)` output. Just as with query and path parameters that
we've seen before, the `String` type parameter specifies that we'd like to serialize the header from a string. We can't
request serializing `AvatarSuccess.Redirect` instances, as Tapir knows nothing about that type. Hence, here we'll have
an `EndpointOutput[String]`:

```scala
//> using dep com.softwaremill.sttp.tapir::tapir-core:@VERSION@

import sttp.model.{HeaderNames, StatusCode}
import sttp.tapir.*

enum AvatarSuccess:
case Found(bytes: Array[Byte])
case Redirect(location: String)

val o1: EndpointOutput[Unit] = statusCode(StatusCode.TemporaryRedirect)
val o2: EndpointOutput[String] = header[String](HeaderNames.Location)
```

We can combine these outputs into a composite output using the `EndpointOutput.and` method. This is similar to adding
multiple outputs to an endpoint description using multiple `Endpoint.out` invocations. In fact, `Endpoint.out`
internally using `EndpointOutput.and` to combine the endpoints defined so far.

The type of the composite output corresponds to the values, that are mapped to the response. As `o1` doesn't map any
values (it's a constant), the composite output will also have the type `EndpintOutput[String]`. Finally, we can map
this output to the `AvatarSuccess.Redirect` type using `.mapTo`, which we've learned about last time:

{emphasize-lines="12-13"}
```scala
//> using dep com.softwaremill.sttp.tapir::tapir-core:@VERSION@

import sttp.model.{HeaderNames, StatusCode}
import sttp.tapir.*

enum AvatarSuccess:
case Found(bytes: Array[Byte])
case Redirect(location: String)

val o1: EndpointOutput[Unit] = statusCode(StatusCode.TemporaryRedirect)
val o2: EndpointOutput[String] = header[String](HeaderNames.Location)
val o3: EndpointOutput[String] = o1.and(o2)
val o3mapped: EndpointOutput[AvatarSuccess.Redirect] = o3.mapTo[AvatarSuccess.Redirect]
```

## Picking the right variant

We're almost ready to define the `oneOf` output with variants. Each variant consists of two parts: the output, and a
function determining (at run-time) if the variant should be used for a given high-level type. That is, when server logic
returns an instance of the high-level type, we need to determine, which variant should be used to map it to the
HTTP response.

The default way to create variants is using the `oneOfVariant(EndpointOutput[T])` method. It creates a description of
a variant, which will match all instances of the `T` type. This check is done by inspecting the run-time class of the
`T` instance. This often works, but not always, as full type information is not always available at run-time, e.g. if
`T` is a generic type. If that's the case, you'll get a compile-time error.

However, for `AvatarSuccess`, this default way of creating variants works just fine, as we are dealing with enum cases,
each of which translates to a separate class. Our one-of successful output takes the following form:

{emphasize-lines="13-16"}
```scala
//> using dep com.softwaremill.sttp.tapir::tapir-core:@VERSION@

import sttp.model.{HeaderNames, StatusCode}
import sttp.tapir.*

enum AvatarSuccess:
case Found(bytes: Array[Byte])
case Redirect(location: String)

val o1: EndpointOutput[Unit] = statusCode(StatusCode.TemporaryRedirect)
val o2: EndpointOutput[String] = header[String](HeaderNames.Location)

val successOutput: EndpointOutput[AvatarSuccess] = oneOf(
oneOfVariant(o1.and(o2).mapTo[AvatarSuccess.Redirect]),
oneOfVariant(byteArrayBody.mapTo[AvatarSuccess.Found])
)
```

The `oneOf` output can be typed using the common parent of both variants, which is `AvatarSuccess`. The server logic
will then have to return an instance of `AvatarSuccess`, in case of successful completion.

```{warn}
Unfortunately, Tapir is not able to verify at compile-time that the variants are exhaustive, that is that every variant
of the high-level type has a corresponding output-variant.
```

## Dealing with singleton enum cases

The output for `AvatarError` can be created similarly, with one caveat. It has two no-parameter cases (`Unauthorized`
and `NotFound`), which are not translated into separate classes by the compiler. Hence, the run-time checks done by
`oneOfVariant` would fail, or more precisely, any no-parameter case would be determined to match the first
no-parameter-case output variant, yielding incorrect responses.

To fix this, we can use the `oneOfVariantExactMatcher` method. It takes an exact value, to which the high-level output
must be equal, which will cause a given output to be chosen:

```scala
//> using dep com.softwaremill.sttp.tapir::tapir-core:@VERSION@

import sttp.model.{HeaderNames, StatusCode}
import sttp.tapir.*

enum AvatarError:
case Unauthorized
case NotFound
case Other(msg: String)

val errorOutput: EndpointOutput[AvatarError] = oneOf(
oneOfVariantExactMatcher(
statusCode(StatusCode.Unauthorized).mapTo[AvatarError.Unauthorized.type])(
AvatarError.Unauthorized),
oneOfVariantExactMatcher(
statusCode(StatusCode.NotFound).mapTo[AvatarError.NotFound.type])(
AvatarError.NotFound),
oneOfVariant(stringBody.mapTo[AvatarError.Other])
)
```

As before, we need to map the variant's output to the type of the specific case, using
`.mapTo[AvatarError.Unauthorized.type]`. We need the `.type` to create a singleton type, as `AvatarError.Unauthorized`
is not a type by itself (and it's not translated to a class).

Additionally, the `oneOfVariantExactMatcher` takes the high-level value, for which the given variant will be used.

## Describing the entire endpoint

Equipped with `oneOf` outputs, we can now fully describe and test our endpoint:

```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:1.10.10

import sttp.model.{HeaderNames, StatusCode}
import sttp.tapir.*
import sttp.tapir.server.netty.sync.NettySyncServer
import sttp.tapir.swagger.bundle.SwaggerInterpreter
import sttp.shared.Identity

enum AvatarError:
case Unauthorized
case NotFound
case Other(msg: String)

enum AvatarSuccess:
case Found(bytes: Array[Byte])
case Redirect(location: String)

val o1: EndpointOutput[Unit] = statusCode(StatusCode.TemporaryRedirect)
val o2: EndpointOutput[String] = header[String](HeaderNames.Location)

val successOutput: EndpointOutput[AvatarSuccess] = oneOf(
oneOfVariant(o1.and(o2).mapTo[AvatarSuccess.Redirect]),
oneOfVariant(byteArrayBody.mapTo[AvatarSuccess.Found])
)

val errorOutput: EndpointOutput[AvatarError] = oneOf(
oneOfVariantExactMatcher(
statusCode(StatusCode.Unauthorized).mapTo[AvatarError.Unauthorized.type])(
AvatarError.Unauthorized),
oneOfVariantExactMatcher(
statusCode(StatusCode.NotFound).mapTo[AvatarError.NotFound.type])(
AvatarError.NotFound),
oneOfVariant(stringBody.mapTo[AvatarError.Other])
)

@main def tapirErrorVariants(): Unit =
val avatarEndpoint = endpoint.get
.in("user" / "avatar")
.in(query[Int]("id"))
.out(successOutput)
.errorOut(errorOutput)
// Int => Either[AvatarError, AvatarSuccess]
.handle {
case 1 => Right(AvatarSuccess.Found(":-)".getBytes))
case 2 => Right(AvatarSuccess.Redirect("https://example.org/me.jpg"))
case 3 => Left(AvatarError.Unauthorized)
case 4 => Left(AvatarError.Other("We don't like this user."))
case _ => Left(AvatarError.NotFound)
}

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

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

As you can see, the server logic needs to return either an `AvatarError`, or a `AvatarSuccess`. This corresponds to the
outputs that we have defined.

Let's run a couple of tests:

```bash
# first console
% scala-cli variants.scala

# second console
% curl -v "http://localhost:8080/user/avatar?id=2"
< HTTP/1.1 307 Temporary Redirect
< server: tapir/1.10.10
< Location: https://example.org/me.jpg

% curl -v "http://localhost:8080/user/avatar?id=7"
< HTTP/1.1 404 Not Found

% curl -v "http://localhost:8080/user/avatar?id=3"
< HTTP/1.1 401 Unauthorized
```

We're also generating documentation. If you take a look at the [`http://localhost:8080/docs`](http://localhost:8080/docs),
you'll see that each status code is properly documented.

## Further reading

There's more ways to define `oneOf` variants, these are described in more detail on the reference page:
[](../endpoint/oneof.md).

0 comments on commit bb939b3

Please sign in to comment.