Exception while trying to integrate tapir with play

Hello!

I’m trying to use tapir with play app and stuck with exception while testing routes. The path: /test/some-name matches the shape of some endpoint, but none of the endpoints decoded the request successfully, and the decode failure handler didn't provide a response. Play requires that if the path shape matches some endpoints, the request should be handled by tapir.
What does it mean - “Play requires that if the path shape matches some endpoints, the request should be handled by tapir.” How to handle requests by tapir if I want need to use play for hanling?

Here my routes:

  val getTestEndpoint: PublicEndpoint[String, TapirError, String, Any] =
    endpoint
      .get
      .in("test" / path[String]("name"))
      .out(stringBody)
      .errorOut(jsonBody[TapirError] and statusCode(StatusCode.BadRequest))

  val deleteTestEndpoint: PublicEndpoint[String, TapirError, String, Any] =
    endpoint
      .delete
      .in("test" / path[String]("name"))
      .out(stringBody)
      .errorOut(jsonBody[TapirError] and statusCode(StatusCode.BadRequest))

And simple logic

  val getTestServerEndpoint = TestEndpoints.getTestEndpoint.serverLogic {
    case (name: String) => Future.successful {
      if (name.isEmpty) {
        Left(TapirError(400, "test.exception", "name is blank", Instant.now()))
      } else {
        Right(s"Hello, $name")
      }
    }
  }

  val deleteTestServerEndpoint = TestEndpoints.deleteTestEndpoint.serverLogic {
    case (name: String) => Future.successful {
      if (name.isEmpty) {
        Left(TapirError(400, "test.exception", "name is blank", Instant.now()))
      } else {
        Right(s"Goodbye, $name")
      }
    }
  }

Test:

    "test overlapping endpoints" in new App(buildApp()) {

      val res = EndpointVerifier(List(WageringEndpoints.getTestEndpoint, WageringEndpoints.deleteTestEndpoint))
      println(s"EndpointVerifier: $res")

      val getHttpRequest = FakeRequest(GET, s"/test/some-name")
      val getFuture: Future[Result] = route(app, getHttpRequest).get
      val getResp = contentAsString(getFuture)
      println(s"GET response: $getResp")

      val deleteHttpRequest = FakeRequest(DELETE, s"/test/some-name")
      val deleteFuture: Future[Result] = route(app, deleteHttpRequest).get
      val deleteResp = contentAsString(deleteFuture)
      println(s"DELETE response: $deleteResp")
    }

Thanks for the report, this has been changed in the last release, so probably we’ve introduced some kind of bug here.

Can you share the full self-contained test w/ imports and missing definitions, so that I could reproduce it locally?

Hm it seems to work fine when I’m testing with a “real” server (binding to a port and making requests using an HTTP client), so maybe it’s a matter of FakeRequest? I don’t have that on my classpath, so I can’t reproduce it just yet

As for explaining the error, see the Note here: Running as a Play server — tapir 1.x documentation

But of course, this should not happen during normal usage.

Thank you for quick response!

I found that exception will be thrown in case if I compose routes like that

override def routes: Routes = getTestRoute.orElse(deleteTestRoute)

But if I use List with endpoints - the test will pass

override def routes: Routes = interpreter.toRoutes(List(getTestServerEndpoint, deleteTestServerEndpoint))

Maybe I just don’t understand something and tried to compose endpoints in wrong way (with orElse). I created sample project with these two endpoints and test - GitHub - nikit-os/play-tapir-exception-example

1 Like

Ah! Yes, that’s the problem.

That’s definitely an omission in the docs - I’ll try to explain this more clearly. There’s also probably a problem with single-endpoint routes, which might need fixing.

The core of the problem is that to integrate with Play, we need to provide a PartialFunction, which has two components:

  • isDefinedAt, which verifies that a request can be handled at all by this route
  • apply, which actually handles this request

In the latest tapir release we optimised the isDefinedAt invocation, so there’s no duplication of work in case a route matches (before the request was decoded twice, to check if the route can be handled). So this is faster now, but there’s a tradeoff: we assume that if there’s at least one endpoint with a matching path shape, the request will be handled by that route.

In your case, the path shape is /test/*. If you have just a GET /test/* endpoint in your route, and issue a DELETE request, the path shape matches, but the request is rejected because of a method mismatch (we don’t include the method in the test for som reasons related to being able to respond with a 405 method not allowed). Hence the exception.

Summing up, it’s not only a working solution, but also more efficient, to interpret multiple endpoints in one go. It’s even required to interpret all endpoints with the same path shape together.

1 Like

See: Fix the way single-endpoint routes are interpreted in Play/ZIO Http. Enable reject tests. by adamw · Pull Request #2760 · softwaremill/tapir · GitHub

This should fix single-endpoint interpreters as in your example. Still, it’s better to interpret multiple endpoints once :slight_smile:

Thank you for explanation! Now I understand how it works. I definitely will be using approach with interpreting multiple endpoints at once =)

1 Like