Skip to content

Commit

Permalink
Introduce oneOfVariantSingletonMatcher (#3899)
Browse files Browse the repository at this point in the history
  • Loading branch information
adamw authored Jul 3, 2024
1 parent bb939b3 commit 4efd75e
Show file tree
Hide file tree
Showing 5 changed files with 69 additions and 23 deletions.
38 changes: 37 additions & 1 deletion core/src/main/scala/sttp/tapir/Tapir.scala
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,8 @@ trait Tapir extends TapirExtensions with TapirComputedInputs with TapirStaticCon
): OneOfVariant[T] =
OneOfVariant(statusCode(code).and(output), matcher.lift.andThen(_.getOrElse(false)))

/** Create a one-of-variant which `output` if the provided value exactly matches one of the values provided in the second argument list.
/** Create a one-of-variant which uses `output` if the provided value exactly matches one of the values provided in the second argument
* list.
*
* Should be used in [[oneOf]] output descriptions.
*/
Expand All @@ -361,6 +362,41 @@ trait Tapir extends TapirExtensions with TapirComputedInputs with TapirStaticCon
): OneOfVariant[T] =
oneOfVariantValueMatcher(code, output)(exactMatch(rest.toSet + firstExactValue))

/** Create a one-of-variant which uses `output` if the provided value equals the singleton value. The `output` shouldn't map to any
* values, that is, it should be `Unit`-typed. The entire variant is, on the other hand, typed with the singleton's type `T`.
*
* Should be used in [[oneOf]] output descriptions.
*
* @see
* [[oneOfVariantExactMatcher]] which allows specifying more exact-match values, and where `output` needs to correspond to type `T`.
*/
def oneOfVariantSingletonMatcher[T](output: EndpointOutput[Unit])(singletonValue: T): OneOfVariant[T] =
oneOfVariantValueMatcher(output.and(emptyOutputAs(singletonValue)))({ case a: Any => a == singletonValue })

/** Create a one-of-variant which uses `output` if the provided value equals the singleton value. The `output` shouldn't map to any
* values, that is, it should be `Unit`-typed. The entire variant is, on the other hand, typed with the singleton's type `T`.
*
* Adds a fixed status-code output with the given value.
*
* Should be used in [[oneOf]] output descriptions.
*
* @see
* [[oneOfVariantExactMatcher]] which allows specifying more exact-match values, and where `output` needs to correspond to type `T`.
*/
def oneOfVariantSingletonMatcher[T](code: StatusCode, output: EndpointOutput[Unit])(singletonValue: T): OneOfVariant[T] =
oneOfVariantValueMatcher(code, output.and(emptyOutputAs(singletonValue)))({ case a: Any => a == singletonValue })

/** Create a one-of-variant which will use a fixed status-code output with the given value, if the provided value equals the singleton
* value. The entire variant is typed with the singleton's type `T`.
*
* Should be used in [[oneOf]] output descriptions.
*
* @see
* [[oneOfVariantExactMatcher]] which allows specifying more exact-match values, and where `output` needs to correspond to type `T`.
*/
def oneOfVariantSingletonMatcher[T](code: StatusCode)(singletonValue: T): OneOfVariant[T] =
oneOfVariantValueMatcher(code, emptyOutputAs(singletonValue))({ case a: Any => a == singletonValue })

/** Create a one-of-variant which uses `output` if the provided value matches the target type, as checked by [[MatchType]]. Instances of
* [[MatchType]] are automatically derived and recursively check that classes of all fields match, to bypass issues caused by type
* erasure.
Expand Down
8 changes: 8 additions & 0 deletions doc/endpoint/oneof.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ val baseEndpoint = endpoint.errorOut(
)
```

### One-of-variant and singleton types

One-of variants can also be created so that they are used only for specific values. This is a specialisation of the
`oneOfVariantValueMatcher` methods, which allows for a more convenient and compact description.

There are two methods which allows working with multiple or single specific values: `oneOfVariantExactMatcher` and
`oneOfVariantSingletonMatcher`.

### Error outputs

Error outputs can be extended with new variants, which is especially useful for partial server endpoints, when the
Expand Down
28 changes: 7 additions & 21 deletions doc/tutorials/06_error_variants.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ and `NotFound`), which are not translated into separate classes by the compiler.
`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:
To fix this, we can use the `oneOfVariantSingletonMatcher` method. It takes a unit-typed output, along with an exact
value, to which the high-level output must be equal, for the variant to be chosen:

```scala
//> using dep com.softwaremill.sttp.tapir::tapir-core:@VERSION@
Expand All @@ -182,30 +182,20 @@ enum AvatarError:
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),
oneOfVariantSingletonMatcher(statusCode(StatusCode.Unauthorized))(AvatarError.Unauthorized),
oneOfVariantSingletonMatcher(statusCode(StatusCode.NotFound))(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
//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:@VERSION@

import sttp.model.{HeaderNames, StatusCode}
import sttp.tapir.*
Expand All @@ -231,12 +221,8 @@ val successOutput: EndpointOutput[AvatarSuccess] = oneOf(
)

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),
oneOfVariantSingletonMatcher(statusCode(StatusCode.Unauthorized))(AvatarError.Unauthorized),
oneOfVariantSingletonMatcher(statusCode(StatusCode.NotFound))(AvatarError.NotFound),
oneOfVariant(stringBody.mapTo[AvatarError.Other])
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ class ServerOneOfTests[F[_], OPTIONS, ROUTE](
basicRequest.response(asStringAlways).get(uri"$baseUri/test").send(backend).map { r =>
r.code shouldBe StatusCode.BadRequest
}
}
},
testServer(in_int_out_value_form_singleton)((num: Int) => pureResult(if (num % 2 == 0) Right("A") else Right("B"))) {
(backend, baseUri) =>
basicRequest.get(uri"$baseUri/mapping?num=1").send(backend).map(_.code shouldBe StatusCode.Ok) >>
basicRequest.get(uri"$baseUri/mapping?num=2").send(backend).map(_.code shouldBe StatusCode.Accepted)
},
)
}
11 changes: 11 additions & 0 deletions tests/src/main/scala/sttp/tapir/tests/OneOf.scala
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,15 @@ object OneOf {
oneOfVariant(statusCode(StatusCode.NotFound).and(jsonBody[FruitErrorDetail.Unknown]))
)
)

val in_int_out_value_form_singleton: PublicEndpoint[Int, Unit, String, Any] =
endpoint
.in("mapping")
.in(query[Int]("num"))
.out(
oneOf(
oneOfVariantSingletonMatcher(StatusCode.Accepted)("A"),
oneOfVariantSingletonMatcher(statusCode(StatusCode.Ok))("B")
)
)
}

0 comments on commit 4efd75e

Please sign in to comment.