Skip to content

Commit

Permalink
Merge branch 'softwaremill:master' into master
Browse files Browse the repository at this point in the history
  • Loading branch information
korlowski authored Dec 16, 2022
2 parents 530881a + 4e3c004 commit d8c37c8
Show file tree
Hide file tree
Showing 9 changed files with 140 additions and 39 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
![tapir](https://github.com/softwaremill/tapir/raw/master/banner.png)

# Happy 1.0 birthday, tapir!
# Welcome!

[![Join the chat at https://gitter.im/softwaremill/tapir](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/softwaremill/tapir?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
[![Ideas, suggestions, problems, questions](https://img.shields.io/badge/Discourse-ask%20question-blue)](https://softwaremill.community/c/tapir)
[![CI](https://github.com/softwaremill/tapir/workflows/CI/badge.svg)](https://github.com/softwaremill/tapir/actions?query=workflow%3A%22CI%22)
[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.softwaremill.sttp.tapir/tapir-core_2.13/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.softwaremill.sttp.tapir/tapir-core_2.13)

Expand Down Expand Up @@ -174,7 +174,7 @@ All suggestions welcome :)
See the list of [issues](https://github.com/softwaremill/tapir/issues) and pick one! Or report your own.

If you are having doubts on the *why* or *how* something works, don't hesitate to ask a question on
[gitter](https://gitter.im/softwaremill/tapir) or via github. This probably means that the documentation, scaladocs or
[discourse](https://softwaremill.community/c/tapir) or via github. This probably means that the documentation, scaladocs or
code is unclear and be improved for the benefit of all.

The `core` module needs to remain binary-compatible with earlier versions. To check if your changes meet this requirement,
Expand Down
32 changes: 20 additions & 12 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -567,14 +567,18 @@ lazy val zio1: ProjectMatrix = (projectMatrix in file("integrations/zio1"))
name := "tapir-zio1",
testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework"),
libraryDependencies ++= Seq(
"dev.zio" %% "zio" % Versions.zio1,
"dev.zio" %% "zio-streams" % Versions.zio1,
"dev.zio" %% "zio-test" % Versions.zio1 % Test,
"dev.zio" %% "zio-test-sbt" % Versions.zio1 % Test,
"com.softwaremill.sttp.shared" %% "zio1" % Versions.sttpShared
"dev.zio" %%% "zio" % Versions.zio1,
"dev.zio" %%% "zio-streams" % Versions.zio1,
"dev.zio" %%% "zio-test" % Versions.zio1 % Test,
"dev.zio" %%% "zio-test-sbt" % Versions.zio1 % Test,
"com.softwaremill.sttp.shared" %%% "zio1" % Versions.sttpShared
)
)
.jvmPlatform(scalaVersions = scala2And3Versions)
.jsPlatform(
scalaVersions = scala2And3Versions,
settings = commonJsSettings
)
.dependsOn(core, serverCore % Test)

lazy val zio: ProjectMatrix = (projectMatrix in file("integrations/zio"))
Expand All @@ -583,14 +587,18 @@ lazy val zio: ProjectMatrix = (projectMatrix in file("integrations/zio"))
name := "tapir-zio",
testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework"),
libraryDependencies ++= Seq(
"dev.zio" %% "zio" % Versions.zio,
"dev.zio" %% "zio-streams" % Versions.zio,
"dev.zio" %% "zio-test" % Versions.zio % Test,
"dev.zio" %% "zio-test-sbt" % Versions.zio % Test,
"com.softwaremill.sttp.shared" %% "zio" % Versions.sttpShared
"dev.zio" %%% "zio" % Versions.zio,
"dev.zio" %%% "zio-streams" % Versions.zio,
"dev.zio" %%% "zio-test" % Versions.zio % Test,
"dev.zio" %%% "zio-test-sbt" % Versions.zio % Test,
"com.softwaremill.sttp.shared" %%% "zio" % Versions.sttpShared
)
)
.jvmPlatform(scalaVersions = scala2And3Versions)
.jsPlatform(
scalaVersions = scala2And3Versions,
settings = commonJsSettings
)
.dependsOn(core, serverCore % Test)

lazy val derevo: ProjectMatrix = (projectMatrix in file("integrations/derevo"))
Expand Down Expand Up @@ -774,8 +782,8 @@ lazy val jsoniterScala: ProjectMatrix = (projectMatrix in file("json/jsoniter"))
.settings(
name := "tapir-jsoniter-scala",
libraryDependencies ++= Seq(
"com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-core" % "2.18.1",
"com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-macros" % "2.18.1" % Test,
"com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-core" % "2.19.1",
"com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-macros" % "2.19.1" % Test,
scalaTest.value % Test
)
)
Expand Down
2 changes: 1 addition & 1 deletion doc/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ If you'd like to contribute, see the list of [issues](https://github.com/softwar
Or report your own. If you have an idea you'd like to discuss, that's always a good option.

If you are having doubts on the *why* or *how* something works, don't hesitate to ask a question on
[gitter](https://gitter.im/softwaremill/tapir) or via github. This probably means that the documentation, scaladocs or
[discourse](https://softwaremill.community/c/tapir) or via github. This probably means that the documentation, scaladocs or
code is unclear and can be improved for the benefit of all.

## Acknowledgments
Expand Down
38 changes: 35 additions & 3 deletions doc/server/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,41 @@ an error or return a "no match", create error messages and create the response.
swapped, e.g. to return responses in a different format (other than plain text), or customise the error messages.

The default decode failure handler also has the option to return a `400 Bad Request`, instead of a no-match (ultimately
leading to a `404 Not Found`), when the "shape" of the path matches (that is, the number of segments in the request
and endpoint's paths are the same), but when decoding some part of the path ends in an error. See the
`badRequestOnPathErrorIfPathShapeMatches` in `ServerDefaults`.
leading to a `404 Not Found`), when the "shape" of the path matches (that is, the constant parts and number of segments
in the request and endpoint's paths are the same), but when decoding some part of the path ends in an error. See the
scaladoc for `DefaultDecodeFailureHandler.default` and parameters of `DefaultDecodeFailureHandler.response`. For example:

```scala mdoc:compile-only
import sttp.tapir._
import sttp.tapir.server.akkahttp.AkkaHttpServerOptions
import sttp.tapir.server.interceptor.decodefailure.DefaultDecodeFailureHandler
import scala.concurrent.ExecutionContext.Implicits.global

val myDecodeFailureHandler = DefaultDecodeFailureHandler.default.copy(
respond = DefaultDecodeFailureHandler.respond(
_,
badRequestOnPathErrorIfPathShapeMatches = true,
badRequestOnPathInvalidIfPathShapeMatches = true
)
)

val myServerOptions: AkkaHttpServerOptions = AkkaHttpServerOptions
.customiseInterceptors
.decodeFailureHandler(myDecodeFailureHandler)
.options
```

Moreover, when using the `DefaultDecodeFailureHandler`, decode failure handling can be overriden on a per-input/output
basis, by setting an attribute. For example:

```scala mdoc:compile-only
import sttp.tapir._
// bringing into scope the onDecodeFailureBadRequest extension method
import sttp.tapir.server.interceptor.decodefailure.DefaultDecodeFailureHandler.OnDecodeFailure._

// by default, when the customer_id is not an int, the next endpoint would be tried; here, we always return a bad request
endpoint.in("customer" / path[Int]("customer_id").onDecodeFailureBadRequest)
```

## Customising how error messages are rendered

Expand Down
2 changes: 1 addition & 1 deletion generated-doc/out/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ If you'd like to contribute, see the list of [issues](https://github.com/softwar
Or report your own. If you have an idea you'd like to discuss, that's always a good option.

If you are having doubts on the *why* or *how* something works, don't hesitate to ask a question on
[gitter](https://gitter.im/softwaremill/tapir) or via github. This probably means that the documentation, scaladocs or
[discourse](https://softwaremill.community/c/tapir) or via github. This probably means that the documentation, scaladocs or
code is unclear and can be improved for the benefit of all.

## Acknowledgments
Expand Down
2 changes: 1 addition & 1 deletion project/Versions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,5 @@ object Versions {
val openTelemetry = "1.20.1"
val mockServer = "5.14.0"
val dogstatsdClient = "4.1.0"
val nettyAll = "4.1.82.Final"
val nettyAll = "4.1.86.Final"
}
2 changes: 1 addition & 1 deletion project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
val sbtSoftwareMillVersion = "2.0.9"
val sbtSoftwareMillVersion = "2.0.12"
addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-common" % sbtSoftwareMillVersion)
addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-publish" % sbtSoftwareMillVersion)
addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-browser-test-js" % sbtSoftwareMillVersion)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,21 @@ object DefaultDecodeFailureHandler {
* The error messages contain information about the source of the decode error, and optionally the validation error detail that caused
* the failure.
*
* The default decode failure handler can be customised by providing alternate functions for deciding whether a response should be sent,
* creating the error message and creating the response.
*
* Furthermore, how decode failures are handled can be adjusted globally by changing the flags passed to [[respond]]. By default, if the
* shape of the path for an endpoint matches the request, but decoding a path capture causes an error (e.g. a `path[Int]("amount")`
* cannot be parsed), the next endpoint is tried. However, if there's a validation error (e.g. a `path[Kind]("kind")`, where `Kind` is an
* enum, and a value outside the enumeration values is provided), a 400 response is sent.
*
* Finally, behavior can be adjusted per-endpoint-input, by setting an attribute. Import the [[OnDecodeFailure]] object and use the
* [[OnDecodeFailure.RichEndpointTransput.onDecodeFailureBadRequest]] and
* [[OnDecodeFailure.RichEndpointTransput.onDecodeFailureNextEndpoint]] extension methods.
*
* This is only used for failures that occur when decoding inputs, not for exceptions that happen when the server logic is invoked.
* Exceptions can be either handled by the server logic, and converted to an error output value. Uncaught exceptions can be handled using
* the [[sttp.tapir.server.interceptor.exception.ExceptionInterceptor]].
*/
val default: DefaultDecodeFailureHandler = DefaultDecodeFailureHandler(
respond(_, badRequestOnPathErrorIfPathShapeMatches = false, badRequestOnPathInvalidIfPathShapeMatches = true),
Expand Down Expand Up @@ -95,35 +109,38 @@ object DefaultDecodeFailureHandler {
badRequestOnPathInvalidIfPathShapeMatches: Boolean
): Option[(StatusCode, List[Header])] = {
failingInput(ctx) match {
case _: EndpointInput.Query[_] => Some(onlyStatus(StatusCode.BadRequest))
case _: EndpointInput.QueryParams[_] => Some(onlyStatus(StatusCode.BadRequest))
case _: EndpointInput.Cookie[_] => Some(onlyStatus(StatusCode.BadRequest))
case i: EndpointTransput.Atom[_] if i.attribute(OnDecodeFailure.key).contains(OnDecodeFailureAttribute(true)) => respondBadRequest
case i: EndpointTransput.Atom[_] if i.attribute(OnDecodeFailure.key).contains(OnDecodeFailureAttribute(false)) => None
case _: EndpointInput.Query[_] => respondBadRequest
case _: EndpointInput.QueryParams[_] => respondBadRequest
case _: EndpointInput.Cookie[_] => respondBadRequest
case h: EndpointIO.Header[_] if ctx.failure.isInstanceOf[DecodeResult.Mismatch] && h.name == HeaderNames.ContentType =>
Some(onlyStatus(StatusCode.UnsupportedMediaType))
case _: EndpointIO.Header[_] => Some(onlyStatus(StatusCode.BadRequest))
respondUnsupportedMediaType
case _: EndpointIO.Header[_] => respondBadRequest
case fh: EndpointIO.FixedHeader[_] if ctx.failure.isInstanceOf[DecodeResult.Mismatch] && fh.h.name == HeaderNames.ContentType =>
Some(onlyStatus(StatusCode.UnsupportedMediaType))
case _: EndpointIO.FixedHeader[_] => Some(onlyStatus(StatusCode.BadRequest))
case _: EndpointIO.Headers[_] => Some(onlyStatus(StatusCode.BadRequest))
case _: EndpointIO.Body[_, _] => Some(onlyStatus(StatusCode.BadRequest))
case _: EndpointIO.OneOfBody[_, _] if ctx.failure.isInstanceOf[DecodeResult.Mismatch] =>
Some(onlyStatus(StatusCode.UnsupportedMediaType))
case _: EndpointIO.StreamBodyWrapper[_, _] => Some(onlyStatus(StatusCode.BadRequest))
respondUnsupportedMediaType
case _: EndpointIO.FixedHeader[_] => respondBadRequest
case _: EndpointIO.Headers[_] => respondBadRequest
case _: EndpointIO.Body[_, _] => respondBadRequest
case _: EndpointIO.OneOfBody[_, _] if ctx.failure.isInstanceOf[DecodeResult.Mismatch] => respondUnsupportedMediaType
case _: EndpointIO.StreamBodyWrapper[_, _] => respondBadRequest
// we assume that the only decode failure that might happen during path segment decoding is an error
// a non-standard path decoder might return Missing/Multiple/Mismatch, but that would be indistinguishable from
// a path shape mismatch
case _: EndpointInput.PathCapture[_]
if (badRequestOnPathErrorIfPathShapeMatches && ctx.failure.isInstanceOf[DecodeResult.Error]) ||
(badRequestOnPathInvalidIfPathShapeMatches && ctx.failure.isInstanceOf[DecodeResult.InvalidValue]) =>
Some(onlyStatus(StatusCode.BadRequest))
respondBadRequest
// if the failing input contains an authentication input (potentially nested), sending its challenge
case FirstAuth(a) => Some((StatusCode.Unauthorized, Header.wwwAuthenticate(a.challenge)))
// other basic endpoints - the request doesn't match, but not returning a response (trying other endpoints)
case _: EndpointInput.Basic[_] => None
// all other inputs (tuples, mapped) - responding with bad request
case _ => Some(onlyStatus(StatusCode.BadRequest))
case _ => respondBadRequest
}
}
private val respondBadRequest = Some(onlyStatus(StatusCode.BadRequest))
private val respondUnsupportedMediaType = Some(onlyStatus(StatusCode.UnsupportedMediaType))

def respondNotFoundIfHasAuth(
ctx: DecodeFailureContext,
Expand Down Expand Up @@ -285,4 +302,15 @@ object DefaultDecodeFailureHandler {
case _ => v
}
}

private[decodefailure] case class OnDecodeFailureAttribute(value: Boolean) extends AnyVal

object OnDecodeFailure {
private[decodefailure] val key: AttributeKey[OnDecodeFailureAttribute] = AttributeKey[OnDecodeFailureAttribute]

implicit class RichEndpointTransput[ET <: EndpointTransput.Atom[_]](val et: ET) extends AnyVal {
def onDecodeFailureBadRequest: ET = et.attribute(key, OnDecodeFailureAttribute(true)).asInstanceOf[ET]
def onDecodeFailureNextEndpoint: ET = et.attribute(key, OnDecodeFailureAttribute(false)).asInstanceOf[ET]
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class ServerBasicTests[F[_], OPTIONS, ROUTE](
methodMatchingTests() ++
pathMatchingTests() ++
pathMatchingMultipleEndpoints() ++
pathShapeMatchingTests() ++
customiseDecodeFailureHandlerTests() ++
serverSecurityLogicTests() ++
(if (inputStreamSupport) inputStreamTests() else Nil) ++
exceptionTests()
Expand Down Expand Up @@ -593,10 +593,10 @@ class ServerBasicTests[F[_], OPTIONS, ROUTE](
}
)

def pathShapeMatchingTests(): List[Test] = List(
def customiseDecodeFailureHandlerTests(): List[Test] = List(
testServer(
in_path_fixed_capture_fixed_capture,
"Returns 400 if path 'shape' matches, but failed to parse a path parameter",
"Returns 400 if path 'shape' matches, but failed to parse a path parameter, using a custom decode failure handler",
_.decodeFailureHandler(decodeFailureHandlerBadRequestOnPathFailure)
)(_ => pureResult(Either.right[Unit, Unit](()))) { (backend, baseUri) =>
basicRequest.get(uri"$baseUri/customer/asd/orders/2").send(backend).map { response =>
Expand All @@ -615,6 +615,39 @@ class ServerBasicTests[F[_], OPTIONS, ROUTE](
.get(uri"$baseUri/customer/asd/orders/2/xyz")
.send(backend)
.map(response => response.code shouldBe StatusCode.NotFound)
}, {
import DefaultDecodeFailureHandler.OnDecodeFailure._
testServer(
endpoint.get.in("customer" / path[Int]("customer_id").onDecodeFailureBadRequest),
"Returns 400 if path 'shape' matches, but failed to parse a path parameter, using .badRequestOnDecodeFailure"
)(_ => pureResult(Either.right[Unit, Unit](()))) { (backend, baseUri) =>
basicRequest.get(uri"$baseUri/customer/asd").send(backend).map { response =>
response.body shouldBe Left("Invalid value for: path parameter customer_id")
response.code shouldBe StatusCode.BadRequest
}
}
}, {
import DefaultDecodeFailureHandler.OnDecodeFailure._
testServer(
"Tries next endpoint if path 'shape' matches, but validation fails, using .onDecodeFailureNextEndpoint",
NonEmptyList.of(
route(
List(
endpoint.get
.in("customer" / path[Int]("customer_id").validate(Validator.min(10)).onDecodeFailureNextEndpoint)
.out(stringBody)
.serverLogic[F]((_: Int) => pureResult("e1".asRight[Unit])),
endpoint.get
.in("customer" / path[String]("customer_id"))
.out(stringBody)
.serverLogic[F]((_: String) => pureResult("e2".asRight[Unit]))
)
)
)
) { (backend, baseUri) =>
basicStringRequest.get(uri"$baseUri/customer/20").send(backend).map(_.body shouldBe "e1") >>
basicStringRequest.get(uri"$baseUri/customer/2").send(backend).map(_.body shouldBe "e2")
}
}
)

Expand Down

0 comments on commit d8c37c8

Please sign in to comment.