Skip to content

Commit a95f01b

Browse files
committed
Add support for additional custom routes in ConnectHttp4sRouteBuilder
1 parent 3e73e73 commit a95f01b

File tree

2 files changed

+195
-1
lines changed

2 files changed

+195
-1
lines changed

http4s/src/main/scala/org/ivovk/connect_rpc_scala/http4s/ConnectHttp4sRouteBuilder.scala

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import org.ivovk.connect_rpc_scala.http4s.Conversions.http4sPathToConnectRpcPath
1313
import org.ivovk.connect_rpc_scala.http4s.connect.{ConnectErrorHandler, ConnectHandler, ConnectRoutesProvider}
1414
import org.ivovk.connect_rpc_scala.http4s.transcoding.{TranscodingHandler, TranscodingRoutesProvider}
1515
import org.ivovk.connect_rpc_scala.transcoding.TranscodingUrlMatcher
16+
import org.ivovk.connect_rpc_scala.util.PipeSyntax.pipe
1617

1718
import java.util.concurrent.Executor
1819
import scala.concurrent.ExecutionContext
@@ -46,6 +47,7 @@ object Http4sRouteBuilder {
4647
waitForShutdown = 5.seconds,
4748
treatTrailersAsHeaders = true,
4849
transcodingErrorHandler = None,
50+
additionalRoutes = None,
4951
)
5052

5153
}
@@ -74,6 +76,7 @@ object ConnectHttp4sRouteBuilder {
7476
waitForShutdown = 5.seconds,
7577
treatTrailersAsHeaders = true,
7678
transcodingErrorHandler = None,
79+
additionalRoutes = None,
7780
)
7881

7982
}
@@ -90,6 +93,7 @@ final class ConnectHttp4sRouteBuilder[F[_]: Async] private[http4s] (
9093
waitForShutdown: Duration,
9194
treatTrailersAsHeaders: Boolean,
9295
transcodingErrorHandler: Option[ErrorHandler[F]],
96+
additionalRoutes: Option[HttpRoutes[F]],
9397
) {
9498

9599
private def copy(
@@ -104,6 +108,7 @@ final class ConnectHttp4sRouteBuilder[F[_]: Async] private[http4s] (
104108
waitForShutdown: Duration = waitForShutdown,
105109
treatTrailersAsHeaders: Boolean = treatTrailersAsHeaders,
106110
transcodingErrorHandler: Option[ErrorHandler[F]] = transcodingErrorHandler,
111+
additionalRoutes: Option[HttpRoutes[F]] = additionalRoutes,
107112
): ConnectHttp4sRouteBuilder[F] =
108113
new ConnectHttp4sRouteBuilder(
109114
services,
@@ -117,6 +122,7 @@ final class ConnectHttp4sRouteBuilder[F[_]: Async] private[http4s] (
117122
waitForShutdown,
118123
treatTrailersAsHeaders,
119124
transcodingErrorHandler,
125+
additionalRoutes,
120126
)
121127

122128
def withServerConfigurator(method: Endo[ServerBuilder[_]]): ConnectHttp4sRouteBuilder[F] =
@@ -173,11 +179,20 @@ final class ConnectHttp4sRouteBuilder[F[_]: Async] private[http4s] (
173179
def withTranscodingErrorHandler(handler: ErrorHandler[F]): ConnectHttp4sRouteBuilder[F] =
174180
copy(transcodingErrorHandler = Some(handler))
175181

182+
/**
183+
* Add your own additional routes to the Connect HTTP app.
184+
*/
185+
def withAdditionalRoutes(routes: HttpRoutes[F]): ConnectHttp4sRouteBuilder[F] =
186+
copy(additionalRoutes = Some(routes))
187+
176188
/**
177189
* Builds a complete HTTP app with all routes.
178190
*/
179191
def build: Resource[F, HttpApp[F]] =
180-
buildRoutes.map(_.all.orNotFound)
192+
for routes <- buildRoutes
193+
yield routes.all
194+
.pipe(ar => additionalRoutes.fold(ar)(ar <+> _))
195+
.orNotFound
181196

182197
/**
183198
* Use this method if you want to add additional routes and/or http4s middleware.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package org.ivovk.connect_rpc_scala.http4s
2+
3+
import cats.effect.*
4+
import cats.effect.unsafe.implicits.global
5+
import org.http4s.client.Client
6+
import org.http4s.dsl.io.*
7+
import org.http4s.headers.`Content-Type`
8+
import org.http4s.implicits.*
9+
import org.http4s.{Method, *}
10+
import org.ivovk.connect_rpc_scala.http.MediaTypes
11+
import org.scalatest.funsuite.AnyFunSuite
12+
import org.scalatest.matchers.should.Matchers
13+
import test.HttpCommunicationTest.TestServiceGrpc.TestService
14+
import test.HttpCommunicationTest.*
15+
16+
import scala.concurrent.{ExecutionContext, Future}
17+
18+
class ConnectHttp4sRouteBuilderTest extends AnyFunSuite with Matchers {
19+
20+
object TestServiceImpl extends TestService {
21+
override def add(request: AddRequest): Future[AddResponse] =
22+
Future.successful(AddResponse(request.a + request.b))
23+
24+
override def get(request: GetRequest): Future[GetResponse] =
25+
Future.successful(GetResponse("Key is: " + request.key))
26+
27+
override def requestBodyMapping(request: RequestBodyMappingRequest): Future[RequestBodyMappingResponse] =
28+
Future.successful(RequestBodyMappingResponse(request.subRequest))
29+
}
30+
31+
// String-JSON encoder
32+
given [F[_]]: EntityEncoder[F, String] = EntityEncoder.stringEncoder[F]
33+
.withContentType(`Content-Type`(MediaTypes.`application/json`))
34+
35+
test("ConnectHttp4sRouteBuilder should support custom path prefixes") {
36+
val service = TestService.bindService(TestServiceImpl, ExecutionContext.global)
37+
38+
ConnectHttp4sRouteBuilder.forService[IO](service)
39+
.withPathPrefix(Root / "api" / "v1")
40+
.build
41+
.flatMap { app =>
42+
Client.fromHttpApp(app).run(
43+
Request[IO](Method.POST, uri"/api/v1/test.TestService/Add")
44+
.withEntity("""{"a": 7, "b": 13}""")
45+
)
46+
}
47+
.use { response =>
48+
for body <- response.as[String]
49+
yield {
50+
response.status shouldBe Status.Ok
51+
body shouldBe """{"sum":20}"""
52+
}
53+
}
54+
.unsafeRunSync()
55+
}
56+
57+
test("ConnectHttp4sRouteBuilder should support custom path prefixes for transcoding") {
58+
val service = TestService.bindService(TestServiceImpl, ExecutionContext.global)
59+
60+
ConnectHttp4sRouteBuilder.forService[IO](service)
61+
.withPathPrefix(Root / "api" / "v2")
62+
.build
63+
.flatMap { app =>
64+
Client.fromHttpApp(app).run(
65+
Request[IO](Method.GET, uri"/api/v2/get/prefix-test")
66+
)
67+
}
68+
.use { response =>
69+
for body <- response.as[String]
70+
yield {
71+
response.status shouldBe Status.Ok
72+
body shouldBe """{"value":"Key is: prefix-test"}"""
73+
}
74+
}
75+
.unsafeRunSync()
76+
}
77+
78+
test("ConnectHttp4sRouteBuilder should return 404 for unknown routes") {
79+
val service = TestService.bindService(TestServiceImpl, ExecutionContext.global)
80+
81+
ConnectHttp4sRouteBuilder.forService[IO](service).build
82+
.flatMap { app =>
83+
Client.fromHttpApp(app).run(
84+
Request[IO](Method.POST, uri"/unknown.Service/UnknownMethod")
85+
.withEntity("""{"data": "test"}""")
86+
)
87+
}
88+
.use { response =>
89+
IO {
90+
response.status shouldBe Status.NotFound
91+
}
92+
}
93+
.unsafeRunSync()
94+
}
95+
96+
test("ConnectHttp4sRouteBuilder should handle invalid JSON gracefully") {
97+
val service = TestService.bindService(TestServiceImpl, ExecutionContext.global)
98+
99+
ConnectHttp4sRouteBuilder.forService[IO](service).build
100+
.flatMap { app =>
101+
Client.fromHttpApp(app).run(
102+
Request[IO](Method.POST, uri"/test.TestService/Add")
103+
.withEntity("""{"invalid": json}""")
104+
)
105+
}
106+
.use { response =>
107+
IO {
108+
response.status shouldBe Status.BadRequest
109+
}
110+
}
111+
.unsafeRunSync()
112+
}
113+
114+
test("ConnectHttp4sRouteBuilder should combine connect and transcoding routes correctly") {
115+
val service = TestService.bindService(TestServiceImpl, ExecutionContext.global)
116+
117+
ConnectHttp4sRouteBuilder.forService[IO](service).buildRoutes
118+
.use { routes =>
119+
val client = Client.fromHttpApp(routes.all.orNotFound)
120+
121+
for {
122+
// Test Connect protocol route
123+
connectResponse <- client.run(
124+
Request[IO](Method.POST, uri"/test.TestService/Add")
125+
.withEntity("""{"a": 1, "b": 2}""")
126+
).use(_.as[String])
127+
128+
// Test Transcoding route
129+
transcodingResponse <- client.run(
130+
Request[IO](Method.GET, uri"/get/combined-test")
131+
).use(_.as[String])
132+
133+
} yield {
134+
connectResponse shouldBe """{"sum":3}"""
135+
transcodingResponse shouldBe """{"value":"Key is: combined-test"}"""
136+
}
137+
}
138+
.unsafeRunSync()
139+
}
140+
141+
test("ConnectHttp4sRouteBuilder should support additional custom routes") {
142+
val service = TestService.bindService(TestServiceImpl, ExecutionContext.global)
143+
144+
// Create custom additional routes
145+
val additionalRoutes = HttpRoutes.of[IO] { case POST -> Root / "custom" / "echo" =>
146+
IO(Response[IO](Status.Ok).withEntity("""{"message":"echo response"}"""))
147+
}
148+
149+
ConnectHttp4sRouteBuilder.forService[IO](service)
150+
.withAdditionalRoutes(additionalRoutes)
151+
.build
152+
.use { app =>
153+
val client = Client.fromHttpApp(app)
154+
155+
for {
156+
echoResponse <- client.run(
157+
Request[IO](Method.POST, uri"/custom/echo")
158+
).use(_.as[String])
159+
160+
// Test that Connect routes still work
161+
connectResponse <- client.run(
162+
Request[IO](Method.POST, uri"/test.TestService/Add")
163+
.withEntity("""{"a": 10, "b": 5}""")
164+
).use(_.as[String])
165+
166+
// Test that Transcoding routes still work
167+
transcodingResponse <- client.run(
168+
Request[IO](Method.GET, uri"/get/additional-test")
169+
).use(_.as[String])
170+
171+
} yield {
172+
echoResponse shouldBe """{"message":"echo response"}"""
173+
connectResponse shouldBe """{"sum":15}"""
174+
transcodingResponse shouldBe """{"value":"Key is: additional-test"}"""
175+
}
176+
}
177+
.unsafeRunSync()
178+
}
179+
}

0 commit comments

Comments
 (0)