From 4efd75eef64c9b64880566d015eb7cbb755ba77f Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Wed, 3 Jul 2024 17:14:27 +0200 Subject: [PATCH] Introduce oneOfVariantSingletonMatcher (#3899) --- core/src/main/scala/sttp/tapir/Tapir.scala | 38 ++++++++++++++++++- doc/endpoint/oneof.md | 8 ++++ doc/tutorials/06_error_variants.md | 28 ++++---------- .../tapir/server/tests/ServerOneOfTests.scala | 7 +++- .../main/scala/sttp/tapir/tests/OneOf.scala | 11 ++++++ 5 files changed, 69 insertions(+), 23 deletions(-) diff --git a/core/src/main/scala/sttp/tapir/Tapir.scala b/core/src/main/scala/sttp/tapir/Tapir.scala index 3366cb1b9a..b15f2b5536 100644 --- a/core/src/main/scala/sttp/tapir/Tapir.scala +++ b/core/src/main/scala/sttp/tapir/Tapir.scala @@ -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. */ @@ -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. diff --git a/doc/endpoint/oneof.md b/doc/endpoint/oneof.md index dbea703f11..be328556a6 100644 --- a/doc/endpoint/oneof.md +++ b/doc/endpoint/oneof.md @@ -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 diff --git a/doc/tutorials/06_error_variants.md b/doc/tutorials/06_error_variants.md index 21e077b997..cbfe762c5c 100644 --- a/doc/tutorials/06_error_variants.md +++ b/doc/tutorials/06_error_variants.md @@ -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@ @@ -182,22 +182,12 @@ 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: @@ -205,7 +195,7 @@ 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.* @@ -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]) ) diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerOneOfTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerOneOfTests.scala index 1e84d1220c..1ac47824b9 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerOneOfTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerOneOfTests.scala @@ -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) + }, ) } diff --git a/tests/src/main/scala/sttp/tapir/tests/OneOf.scala b/tests/src/main/scala/sttp/tapir/tests/OneOf.scala index e2989f0d08..829adb0a51 100644 --- a/tests/src/main/scala/sttp/tapir/tests/OneOf.scala +++ b/tests/src/main/scala/sttp/tapir/tests/OneOf.scala @@ -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") + ) + ) }