Trailing path routes override all Method.ANY routes (breaks Tapir interop)
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 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
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)
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:
- path
- method
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.
Maybe there should be another Method variant for this usecase, Method.EVERY?
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.
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.
/bounty $250
💎 $250 bounty • ZIO
Steps to solve:
- Start working: Comment
/attempt #3374with your implementation plan - Submit work: Create a pull request including
/claim #3374in the PR body to claim the bounty - 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 |
🎉🎈 @987Nabil has been awarded $250 by ZIO! 🎈🎊