zio-http icon indicating copy to clipboard operation
zio-http copied to clipboard

Trailing path routes override all Method.ANY routes (breaks Tapir interop)

Open godspeedelbow opened this issue 9 months ago • 1 comments

Describe the bug

Both GET /foo and GET /bar get caught by the catchAll route even though, GET /foo ought to be matched by the Method.ANY / "foo" route. This breaks the Tapir zio-http interoperability, see https://github.com/softwaremill/tapir/issues/4381#issuecomment-2689995103.

To Reproduce

//> using dep "dev.zio::zio:2.1.15"
//> using dep "dev.zio::zio-http:3.0.1"

import zio.*
import zio.http.*

object CatchAllOverrideBug extends ZIOAppDefault:

  val foo = Routes(
    Method.ANY / "foo" -> handler(Response.text("foo"))
  ) @@ Middleware.debug

  val catchAll = Routes(
    Method.GET / trailing -> handler(Response.status(Status.fromInt(418)))
  ) @@ Middleware.debug

  val routes = foo ++ catchAll

  override def run: ZIO[ZIOAppArgs & Scope, Any, Any] =
    val exists = Request.get("/foo")
    val existsNot = Request.get("/bar")

    routes.run(exists).debug *>
      routes.run(existsNot).debug

    /*  logs:
      418 GET /foo 245ms
      Response(Ok,Headers((content-type,text/plain)),Body.fromAsciiString(catchAll))  <-- bad
      418 GET /bar 0ms
      Response(Ok,Headers((content-type,text/plain)),Body.fromAsciiString(catchAll))  <-- good
     */
  end run
end CatchAllOverrideBug

Expected behaviour I expect GET /foo requests to be matched by the Method.ANY / "foo" route.

godspeedelbow avatar Mar 03 '25 13:03 godspeedelbow

@godspeedelbow I am not sure we can solve this in zio-http. A GET should always take precedence over an ANY. That is works as designed. Only if the path does not match, then ANY is tried. And trailing always matches. Changing this does not seem to fit the main use case of zio-http. Which is not tapir. I don't say we don't want to try, but we do not want to suffer performance on the non tapir path. A solution might need changes in both, zio-http and tapir. I don't see a quick fix.

cc @jdegoes @kyri-petrou

987Nabil avatar Mar 05 '25 17:03 987Nabil

Maybe I'm misunderstanding the point of the ANY method but disregarding tapir I would expect this behavior as well.

It makes sense to me that path would take precedence over the method, you don't organize functionality on your server by method, you start with path then filter by the method.

My specific use case is serving static files at the root while providing a /proxy endpoint to forward requests. It'd be nice to use Method.ANY for my /proxy / trailing endpoint but if I do, all the GET requests get handled (and 404ed) by Middleware.serveResources(Path.empty)

dylanowen avatar Aug 23 '25 00:08 dylanowen

Here is an example:

object ExampleBug extends ZIOAppDefault {

  private val routes = Routes(
    Method.ANY / "proxy" / trailing -> handler { (path: Path, req: Request) =>
      {
        for {
          baseUrl <- ZIO.fromEither(URL.decode("https://example.com"))
          result <- Client.batched(req.copy(url = baseUrl.path(path), headers = Headers.empty))
        } yield result
      }
    }.sandbox
  ) @@ Middleware.serveResources(Path.empty)

  override def run: Task[Nothing] = {
    val config = Server.Config.default
      .maxHeaderSize(8192 * 4) // give enough space for full alpaca cert chains
      .port(4000)

    val layer = ZLayer.make[Server & Client](
      ZLayer.succeed({ config }),
      Server.live,
      Client.default,
    )

    Server
      .serve(routes)
      .provide(layer)
  }
}

the /proxy route will never match for a GET request. Again, maybe I'm misunderstanding ANY but I would expect the precedence to be:

  1. path
  2. method

dylanowen avatar Aug 26 '25 18:08 dylanowen

ANY is explicitly designed to be a catch all with the lowest precedence. Only your use case would work better if the behavior would be different. /proxy could be a resource. Then which one do you take? Also we do not add a new path for each resource, this is a dynamic path. The real issue here, is that your resources have no prefix. That is a smell. That said, I think there is an issue with the resources middleware. It seems to add its routes in the wrong order.

987Nabil avatar Aug 27 '25 20:08 987Nabil

Maybe there should be another Method variant for this usecase, Method.EVERY?

godspeedelbow avatar Sep 01 '25 10:09 godspeedelbow

I kinda dislike adding complexity for everyone to help out tapir. What is tapir doing internally? I think that might be where a fix is applied easier.

987Nabil avatar Sep 01 '25 14:09 987Nabil

I'd still argue this isn't a Tapir specific problem. It doesn't make sense to prioritize a method over a path. There are many reasons you'd want to have a route that could be called for ANY method and you wouldn't want it to be clobbered by a dynamic route.

/proxy could be a resource. Then which one do you take?

Method.ANY / "proxy" / trailing would naturally take precedence since it's more specific, Resources are defined dynamically.

The real issue here, is that your resources have no prefix. That is a smell.

Is it, what do you host at the root of your server if not a index.html? Even if it is a smell, why would zio-http be prescriptive for this? The same problem still comes up if you have:

object ExampleBug extends ZIOAppDefault {

  private val routes = Routes(
    Method.ANY / "a" / "proxy" / trailing -> handler { (path: Path, req: Request) =>
      {
        for {
          baseUrl <- ZIO.fromEither(URL.decode("https://example.com"))
          result <- Client.batched(req.copy(url = baseUrl.path(path), headers = Headers.empty))
        } yield result
      }
    }.sandbox
  ) @@ Middleware.serveResources(Path("a"))

  override def run: Task[Nothing] = {
    val config = Server.Config.default
      .maxHeaderSize(8192 * 4) // give enough space for full alpaca cert chains
      .port(4000)

    val layer = ZLayer.make[Server & Client](
      ZLayer.succeed({ config }),
      Server.live,
      Client.default,
    )

    Server
      .serve(routes)
      .provide(layer)
  }
}

Putting resources at the root is just an example.

dylanowen avatar Sep 04 '25 21:09 dylanowen

/bounty $250

jdegoes avatar Oct 08 '25 15:10 jdegoes

💎 $250 bounty • ZIO

Steps to solve:

  1. Start working: Comment /attempt #3374 with your implementation plan
  2. Submit work: Create a pull request including /claim #3374 in the PR body to claim the bounty
  3. Receive payment: 100% of the bounty is received 2-5 days post-reward. Make sure you are eligible for payouts

❗ Important guidelines:

  • To claim a bounty, you need to provide a short demo video of your changes in your pull request
  • If anything is unclear, ask for clarification before starting as this will help avoid potential rework
  • Low quality AI PRs will not receive review and will be closed
  • Do not ask to be assigned unless you've contributed before

Thank you for contributing to zio/zio-http!

Attempt Started (UTC) Solution Actions
🟢 @987Nabil Oct 08, 2025, 03:30:17 PM #3712 Reward

algora-pbc[bot] avatar Oct 08 '25 15:10 algora-pbc[bot]

🎉🎈 @987Nabil has been awarded $250 by ZIO! 🎈🎊

algora-pbc[bot] avatar Oct 08 '25 15:10 algora-pbc[bot]