Skip to content

Commit 896a8dd

Browse files
xerialclaude
andcommitted
fix: Encode Router @endpoint case-class returns via Weaver (#537)
`RouterHandler` now pre-computes `Weaver.fromSurface(returnType)` for every route and passes it to `ResponseConverter.toResponse`, so case-class results from `@Endpoint` controllers are serialized as proper JSON instead of `value.toString` wrapped in quotes. The weaver is derived against the inner element surface — peeling `Rx[_]` and `Option[_]` — so it lines up with `ResponseConverter`'s existing wrapper unwrapping (Rx → unwrap, Option → 204 noContent on None). `ResponseConverter.toResponse(result)` keeps its old toString-fallback behavior for callers that don't supply a weaver; the new weaver-aware overload routes anything that isn't a shape-special type (Response, null, Unit, String, JSONValue, Array[Byte], Rx, Option) through the weaver. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b52cb81 commit 896a8dd

3 files changed

Lines changed: 116 additions & 20 deletions

File tree

uni-netty/src/main/scala/wvlet/uni/http/netty/RouterHandler.scala

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import wvlet.uni.http.{Request, Response}
1717
import wvlet.uni.http.router.*
1818
import wvlet.uni.log.LogSupport
1919
import wvlet.uni.rx.Rx
20+
import wvlet.uni.surface.{OptionSurface, Surface}
21+
import wvlet.uni.weaver.Weaver
2022

2123
/**
2224
* An RxHttpHandler implementation that dispatches requests to controller methods based on route
@@ -34,6 +36,17 @@ class RouterHandler(router: Router, controllerProvider: ControllerProvider)
3436
private val matcher = RouteMatcher(router.routes)
3537
private val mapper = HttpRequestMapper()
3638

39+
// Pre-compute Weavers for each route's return type so case-class results are encoded as JSON.
40+
// The weaver is derived against the inner element surface (Rx[A] / Option[A] are peeled) to
41+
// match how ResponseConverter unwraps these wrappers before encoding the value.
42+
private val returnWeavers: Map[Route, Weaver[?]] =
43+
router
44+
.routes
45+
.map { r =>
46+
r -> Weaver.fromSurface(RouterHandler.elementSurface(r.methodSurface.returnType))
47+
}
48+
.toMap
49+
3750
// Lazily initialized filter instance (thread-safe)
3851
private lazy val filterInstance: Option[RxHttpFilter] = router
3952
.filterSurfaceOpt
@@ -61,7 +74,7 @@ class RouterHandler(router: Router, controllerProvider: ControllerProvider)
6174
Some(controller)
6275
)
6376
val result = routeMatch.route.methodSurface.call(controller, args*)
64-
ResponseConverter.toResponse(result)
77+
ResponseConverter.toResponse(result, returnWeavers(routeMatch.route))
6578
catch
6679
case e: HttpRequestMappingException =>
6780
debug(s"Parameter mapping error: ${e.getMessage}")
@@ -77,6 +90,20 @@ end RouterHandler
7790

7891
object RouterHandler:
7992

93+
/**
94+
* Peel `Rx[_]` and `Option[_]` from a return-type surface, since [[ResponseConverter]] unwraps
95+
* these wrappers before encoding the inner value. The weaver derived from the resulting surface
96+
* is what eventually handles JSON serialization for case classes, sequences, etc.
97+
*/
98+
private[netty] def elementSurface(surface: Surface): Surface =
99+
surface match
100+
case opt: OptionSurface =>
101+
elementSurface(opt.elementSurface)
102+
case s if classOf[Rx[?]].isAssignableFrom(s.rawType) && s.typeArgs.nonEmpty =>
103+
elementSurface(s.typeArgs.head)
104+
case other =>
105+
other
106+
80107
/**
81108
* Create a RouterHandler with a SimpleControllerProvider.
82109
*/

uni-netty/src/test/scala/wvlet/uni/http/netty/NettyServerTest.scala

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
package wvlet.uni.http.netty
1515

1616
import wvlet.uni.http.{HttpHandler, HttpMethod, Request, Response, ServerSentEvent}
17+
import wvlet.uni.http.router.{Endpoint, Router}
1718
import wvlet.uni.rx.Rx
1819
import wvlet.uni.test.UniTest
1920

@@ -333,6 +334,29 @@ class NettyServerTest extends UniTest:
333334
}
334335
}
335336

337+
test("should encode case class returns from @Endpoint controllers as JSON") {
338+
NettyServer
339+
.withPort(0)
340+
.withRxHandler(RouterHandler(Router.of[NettyServerTest.HelloController]))
341+
.start { server =>
342+
val r = get(s"http://localhost:${server.localPort}/hello")
343+
r.statusCode() shouldBe 200
344+
r.body() shouldBe """{"message":"world"}"""
345+
r.headers().firstValue("Content-Type").orElse("") shouldContain "application/json"
346+
347+
val rOpt = get(s"http://localhost:${server.localPort}/maybe")
348+
rOpt.statusCode() shouldBe 200
349+
rOpt.body() shouldBe """{"message":"some"}"""
350+
351+
val rRx = get(s"http://localhost:${server.localPort}/rx")
352+
rRx.statusCode() shouldBe 200
353+
rRx.body() shouldBe """{"message":"reactive"}"""
354+
355+
val rNone = get(s"http://localhost:${server.localPort}/none")
356+
rNone.statusCode() shouldBe 204
357+
}
358+
}
359+
336360
test("should detect benign I/O exceptions") {
337361
import java.io.IOException
338362
import java.nio.channels.ClosedChannelException
@@ -358,3 +382,19 @@ class NettyServerTest extends UniTest:
358382
}
359383

360384
end NettyServerTest
385+
386+
object NettyServerTest:
387+
case class Greeting(message: String)
388+
389+
class HelloController:
390+
@Endpoint(HttpMethod.GET, "/hello")
391+
def hello: Greeting = Greeting("world")
392+
393+
@Endpoint(HttpMethod.GET, "/maybe")
394+
def maybe: Option[Greeting] = Some(Greeting("some"))
395+
396+
@Endpoint(HttpMethod.GET, "/none")
397+
def none: Option[Greeting] = None
398+
399+
@Endpoint(HttpMethod.GET, "/rx")
400+
def rx: Rx[Greeting] = Rx.single(Greeting("reactive"))

uni/src/main/scala/wvlet/uni/http/router/ResponseConverter.scala

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package wvlet.uni.http.router
1616
import wvlet.uni.http.{HttpContent, Response}
1717
import wvlet.uni.json.JSON.JSONValue
1818
import wvlet.uni.rx.Rx
19+
import wvlet.uni.weaver.Weaver
1920

2021
/**
2122
* Converts controller method return values to HTTP responses.
@@ -39,19 +40,40 @@ object ResponseConverter:
3940
* @return
4041
* An Rx that emits the HTTP response
4142
*/
42-
def toResponse(result: Any): Rx[Response] =
43+
def toResponse(result: Any): Rx[Response] = toResponse(result, None)
44+
45+
/**
46+
* Convert a method return value to an Rx[Response] using the given Weaver to encode case classes
47+
* and other complex types as JSON.
48+
*
49+
* The weaver is expected to be derived for the inner element type, after peeling [[Rx]] and
50+
* [[Option]] from the controller method's declared return type.
51+
*
52+
* @param result
53+
* The return value from a controller method
54+
* @param returnWeaver
55+
* Weaver for the inner element type
56+
* @return
57+
* An Rx that emits the HTTP response
58+
*/
59+
def toResponse(result: Any, returnWeaver: Weaver[?]): Rx[Response] = toResponse(
60+
result,
61+
Some(returnWeaver)
62+
)
63+
64+
private def toResponse(result: Any, weaverOpt: Option[Weaver[?]]): Rx[Response] =
4365
result match
4466
case r: Response =>
4567
Rx.single(r)
4668
case rx: Rx[?] =>
47-
rx.map(convertToResponse)
69+
rx.map(convertToResponse(_, weaverOpt))
4870
case other =>
49-
Rx.single(convertToResponse(other))
71+
Rx.single(convertToResponse(other, weaverOpt))
5072

5173
/**
5274
* Convert a single value to an HTTP response.
5375
*/
54-
private def convertToResponse(value: Any): Response =
76+
private def convertToResponse(value: Any, weaverOpt: Option[Weaver[?]]): Response =
5577
value match
5678
case r: Response =>
5779
r
@@ -65,27 +87,34 @@ object ResponseConverter:
6587
Response.ok.withContent(HttpContent.json(json))
6688
case bytes: Array[Byte] =>
6789
Response.ok.withBytesContent(bytes)
68-
case seq: Seq[?] =>
69-
// Convert sequences to JSON arrays
70-
Response.ok.withContent(HttpContent.json(seqToJson(seq)))
71-
case map: Map[?, ?] =>
72-
// Convert maps to JSON objects
73-
Response.ok.withContent(HttpContent.json(mapToJson(map)))
7490
case opt: Option[?] =>
7591
opt match
7692
case Some(v) =>
77-
convertToResponse(v)
93+
convertToResponse(v, weaverOpt)
7894
case None =>
7995
Response.noContent
8096
case other =>
81-
// Try to serialize as JSON
82-
try
83-
val jsonStr = toJsonString(other)
84-
Response.ok.withJsonContent(jsonStr)
85-
catch
86-
case e: Exception =>
87-
// Fall back to toString
88-
Response.ok.withTextContent(other.toString)
97+
weaverOpt match
98+
case Some(weaver) =>
99+
try
100+
val jsonStr = weaver.asInstanceOf[Weaver[Any]].toJson(other)
101+
Response.ok.withJsonContent(jsonStr)
102+
catch
103+
case e: Exception =>
104+
Response.ok.withTextContent(other.toString)
105+
case None =>
106+
other match
107+
case seq: Seq[?] =>
108+
Response.ok.withContent(HttpContent.json(seqToJson(seq)))
109+
case map: Map[?, ?] =>
110+
Response.ok.withContent(HttpContent.json(mapToJson(map)))
111+
case _ =>
112+
try
113+
val jsonStr = toJsonString(other)
114+
Response.ok.withJsonContent(jsonStr)
115+
catch
116+
case e: Exception =>
117+
Response.ok.withTextContent(other.toString)
89118

90119
/**
91120
* Convert a sequence to a JSON array string.

0 commit comments

Comments
 (0)