Skip to content

Commit 42267ee

Browse files
xerialclaude
andauthored
Support global RxRouter filters for unmatched routes (e.g., CORS preflight) (#4172)
## Summary - Fix: When using `RxRouter.filter(corsFilter).andThen(RxRouter.of[Controller])`, OPTIONS preflight requests returned 404 because the route matcher groups routes by HTTP method — unmatched methods fell through to 404 before global filters could run. - Track accumulated filter chains from non-leaf filter nodes as a `fallbackFilter` in `RoutingTable`. When route matching fails, apply the fallback filter so global filters like CORS can intercept unmatched requests. - Add integration test verifying OPTIONS preflight and CORS headers work with controller-based routing. ## Test plan - [x] New `CorsControllerTest` verifies OPTIONS preflight returns CORS headers with controller-based routing - [x] New test verifies CORS headers are added to normal POST responses - [x] Existing `CorsFilterTest` (leaf endpoint pattern) still passes — no regression 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 265eaa0 commit 42267ee

3 files changed

Lines changed: 175 additions & 21 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package wvlet.airframe.http.netty
15+
16+
import wvlet.airframe.http.*
17+
import wvlet.airframe.http.client.SyncClient
18+
import wvlet.airframe.http.filter.Cors
19+
import wvlet.airspec.AirSpec
20+
21+
object CorsControllerTest extends AirSpec {
22+
@RPC
23+
class MyApi {
24+
def hello(msg: String): String = s"Hello ${msg}!"
25+
}
26+
27+
private val corsRouter = RxRouter
28+
.filter(Cors.newFilter(Cors.unsafePermissivePolicy))
29+
.andThen(RxRouter.of[MyApi])
30+
}
31+
32+
/**
33+
* Test that CORS filter works with controller-based routing via RxRouter.filter(...). Previously, OPTIONS preflight
34+
* requests would return 404 because the route matcher rejects unmatched HTTP methods before filters run.
35+
*/
36+
class CorsControllerTest extends AirSpec {
37+
import CorsControllerTest.*
38+
39+
initDesign { d =>
40+
Netty.server.withRouter(corsRouter).designWithSyncClient
41+
}
42+
43+
test("handle OPTIONS preflight for controller routes") { (client: SyncClient) =>
44+
val resp = client.send(
45+
Http
46+
.request(HttpMethod.OPTIONS, "/wvlet.airframe.http.netty.CorsControllerTest.MyApi/hello")
47+
.withHeader("Origin", "https://example.com")
48+
.withHeader("Access-Control-Request-Method", "POST")
49+
.withHeader("Access-Control-Request-Headers", "Content-Type")
50+
)
51+
resp.statusCode shouldBe 200
52+
resp.header.get("access-control-allow-origin") shouldBe Some("https://example.com")
53+
resp.header.get("access-control-allow-methods") shouldBe Some("POST")
54+
resp.header.get("access-control-allow-headers") shouldBe Some("Content-Type")
55+
resp.header.get("access-control-allow-credentials") shouldBe Some("true")
56+
}
57+
58+
test("add CORS headers to normal responses") { (client: SyncClient) =>
59+
val resp = client.send(
60+
Http
61+
.POST("/wvlet.airframe.http.netty.CorsControllerTest.MyApi/hello")
62+
.withJson("""{"msg":"World"}""")
63+
.withHeader("Origin", "https://example.com")
64+
)
65+
resp.statusCode shouldBe 200
66+
resp.contentString shouldBe "Hello World!"
67+
resp.header.get("access-control-allow-origin") shouldBe Some("https://example.com")
68+
resp.header.get("access-control-allow-credentials") shouldBe Some("true")
69+
}
70+
}

airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/HttpRequestDispatcher.scala

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import wvlet.airframe.codec.MessageCodecFactory
1818
import wvlet.airframe.http.*
1919
import wvlet.log.LogSupport
2020

21+
import scala.annotation.tailrec
2122
import scala.concurrent.ExecutionContext
2223
import scala.language.higherKinds
2324

@@ -30,7 +31,10 @@ object HttpRequestDispatcher extends LogSupport {
3031

3132
case class RoutingTable[Req, Resp, F[_]](
3233
routeToFilterMappings: Map[Route, RouteFilter[Req, Resp, F]],
33-
leafFilter: Option[HttpFilter[Req, Resp, F]]
34+
leafFilter: Option[HttpFilter[Req, Resp, F]],
35+
// Global filter chain extracted from the root of the Router tree.
36+
// Applied before route matching so filters like CORS can intercept all requests.
37+
globalFilter: Option[HttpFilter[Req, Resp, F]] = None
3438
) {
3539
def findFilter(route: Route): RouteFilter[Req, Resp, F] = {
3640
routeToFilterMappings(route)
@@ -49,7 +53,8 @@ object HttpRequestDispatcher extends LogSupport {
4953
// Generate a table for Route -> matching HttpFilter
5054
val routingTable = buildRoutingTable(backend, session, router, backend.defaultFilter, controllerProvider)
5155

52-
backend.newFilter { (request: Req, context: HttpContext[Req, Resp, F]) =>
56+
// Route dispatcher: matches requests to routes and applies route-specific filters
57+
val routeDispatcher = backend.newFilter { (request: Req, context: HttpContext[Req, Resp, F]) =>
5358
router.findRoute(request) match {
5459
case Some(routeMatch) =>
5560
// Find a filter for the matched route
@@ -76,6 +81,11 @@ object HttpRequestDispatcher extends LogSupport {
7681
}
7782
}
7883
}
84+
85+
// Wrap with global filter so it runs before route matching.
86+
// This allows filters like CORS to intercept requests (e.g., OPTIONS preflight)
87+
// regardless of whether a route matches.
88+
routingTable.globalFilter.map(_.andThen(routeDispatcher)).getOrElse(routeDispatcher)
7989
}
8090

8191
/**
@@ -88,29 +98,56 @@ object HttpRequestDispatcher extends LogSupport {
8898
baseFilter: HttpFilter[Req, Resp, F],
8999
controllerProvider: ControllerProvider
90100
): RoutingTable[Req, Resp, F] = {
101+
102+
def adaptFilter(router: Router): Option[HttpFilter[Req, Resp, F]] =
103+
router.filterInstance
104+
.orElse {
105+
router.filterSurface.flatMap(fs => controllerProvider.findController(session, fs))
106+
}
107+
.map {
108+
case rxFilter: RxHttpFilter =>
109+
backend.rxFilterAdapter(rxFilter)
110+
case legacyFilter: HttpFilter[Req, Resp, F] @unchecked =>
111+
backend.filterAdapter(legacyFilter)
112+
case other =>
113+
throw RPCStatus.UNIMPLEMENTED_U8.newException(s"Invalid filter type: ${other}")
114+
}
115+
116+
// Extract global filters from the root's linear single-child path.
117+
// These filters wrap the entire dispatch so they run before route matching.
118+
val (globalFilter, routeRoot) = {
119+
@tailrec
120+
def walk(
121+
r: Router,
122+
acc: Option[HttpFilter[Req, Resp, F]]
123+
): (Option[HttpFilter[Req, Resp, F]], Router) = {
124+
val localFilter = adaptFilter(r)
125+
val newAcc = (acc, localFilter) match {
126+
case (Some(a), Some(lf)) => Some(a.andThen(lf))
127+
case (None, Some(lf)) => Some(lf)
128+
case (a, None) => a
129+
}
130+
131+
if (r.localRoutes.isEmpty && r.children.size == 1) {
132+
// Single child with no routes — continue walking
133+
walk(r.children.head, newAcc)
134+
} else if (localFilter.isDefined) {
135+
// Filter with routes or branching — extract filter, return node without it
136+
(newAcc, r.copy(filterInstance = None, filterSurface = None))
137+
} else {
138+
(acc, r)
139+
}
140+
}
141+
walk(rootRouter, None)
142+
}
143+
91144
val leafFilters = Seq.newBuilder[HttpFilter[Req, Resp, F]]
92145

93146
def buildMappingsFromRouteToFilter(
94147
router: Router,
95148
parentFilter: HttpFilter[Req, Resp, F]
96149
): Map[Route, RouteFilter[Req, Resp, F]] = {
97-
val localFilterOpt: Option[HttpFilter[Req, Resp, F]] =
98-
// Use a given filter instance or one created from the DI session
99-
router.filterInstance
100-
.orElse {
101-
router.filterSurface
102-
.map(fs => controllerProvider.findController(session, fs))
103-
.filter(_.isDefined)
104-
.map(_.get)
105-
}
106-
.map {
107-
case rxFilter: RxHttpFilter =>
108-
backend.rxFilterAdapter(rxFilter)
109-
case legacyFilter: HttpFilter[Req, Resp, F] @unchecked =>
110-
backend.filterAdapter(legacyFilter)
111-
case other =>
112-
throw RPCStatus.UNIMPLEMENTED_U8.newException(s"Invalid filter type: ${other}") //
113-
}
150+
val localFilterOpt = adaptFilter(router)
114151

115152
val currentFilter: HttpFilter[Req, Resp, F] =
116153
localFilterOpt
@@ -139,14 +176,14 @@ object HttpRequestDispatcher extends LogSupport {
139176
m.result()
140177
}
141178

142-
val mappings = buildMappingsFromRouteToFilter(rootRouter, baseFilter)
179+
val mappings = buildMappingsFromRouteToFilter(routeRoot, baseFilter)
143180

144181
val lf = leafFilters.result()
145182
if (lf.size > 1) {
146183
warn(s"Multiple leaf filters are found in the router. Using the first one: ${lf.head}")
147184
}
148185

149-
RoutingTable(mappings, lf.headOption)
186+
RoutingTable(mappings, lf.headOption, globalFilter)
150187
}
151188

152189
}

docs/airframe-http.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,53 @@ val router = RxRouter
390390
Using local variables inside filters will not work because the request processing will happen when Future[X] is evaluated, so we must use thead-local parmeter holder, which will be prepared for each request call.
391391

392392

393+
### CORS Filter
394+
395+
airframe-http provides a built-in CORS filter for handling [Cross-Origin Resource Sharing](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). To use it, add the CORS filter to your router with `RxRouter.filter(...)`:
396+
397+
```scala
398+
import wvlet.airframe.http.*
399+
import wvlet.airframe.http.filter.Cors
400+
import wvlet.airframe.http.netty.Netty
401+
402+
@RPC
403+
class MyApi {
404+
def hello(msg: String): String = s"Hello ${msg}!"
405+
}
406+
407+
// Define a CORS policy
408+
val corsPolicy = Cors.Policy(
409+
allowsOrigin = {
410+
case origin if origin.endsWith(".mydomain.com") => Some(origin)
411+
case _ => None
412+
},
413+
allowsMethods = _ => Some(Seq("GET", "POST")),
414+
allowsHeaders = headers => Some(headers),
415+
supportsCredentials = true
416+
)
417+
418+
// Add the CORS filter to the router
419+
val router = RxRouter
420+
.filter(Cors.newFilter(corsPolicy))
421+
.andThen(RxRouter.of[MyApi])
422+
423+
Netty.server
424+
.withRouter(router)
425+
.start { server =>
426+
// Server handles CORS preflight (OPTIONS) and adds CORS headers to all responses
427+
}
428+
```
429+
430+
The CORS filter placed at the router level intercepts `OPTIONS` preflight requests before route matching, so it works correctly with `@RPC` and `@Endpoint` controller routes. For non-OPTIONS requests, it adds the appropriate CORS headers to responses.
431+
432+
For development or testing, you can use the permissive policy that allows all origins (do not use in production):
433+
```scala
434+
val router = RxRouter
435+
.filter(Cors.newFilter(Cors.unsafePermissivePolicy))
436+
.andThen(RxRouter.of[MyApi])
437+
```
438+
439+
393440
## Access Logs
394441

395442
airframe-http stores HTTP access logs at `log/http-server.json` by default in JSON format. When the log file becomes large, it will be compressed with gz and rotated automatically.

0 commit comments

Comments
 (0)