feature/(http4s): add proxy to lift for parity testing

Add a proxy route in Http4s500 to forward unmatched requests to the legacy Lift framework, enabling contract parity testing between the two implementations. This allows new http4s endpoints to be tested against existing Lift behavior.

Update V500ContractParityTest to include a test for the private accounts endpoint, verifying both implementations return consistent responses. Simplify assertion syntax from `should not be empty` to `isDefined shouldBe true` for clarity.
This commit is contained in:
hongwei 2026-01-29 10:30:08 +01:00
parent 97ccc77a86
commit 9a6368bf80
2 changed files with 79 additions and 2 deletions

View File

@ -5,6 +5,7 @@ import cats.effect._
import code.api.Constant._
import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._
import code.api.util.APIUtil.{EmptyBody, ResourceDoc}
import code.api.util.APIUtil
import code.api.util.ApiTag._
import code.api.util.ErrorMessages._
import code.api.util.http4s.ResourceDocMiddleware
@ -19,6 +20,8 @@ import com.openbankproject.commons.model.BankId
import com.openbankproject.commons.model.ProductCode
import com.openbankproject.commons.dto.GetProductsParam
import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion}
import dispatch.{Http => DispatchHttp, as => DispatchAs, url => DispatchUrl}
import java.nio.charset.StandardCharsets
import net.liftweb.json.JsonAST.prettyRender
import net.liftweb.json.{Extraction, Formats}
import org.http4s._
@ -54,6 +57,8 @@ object Http4s500 {
object Implementations5_0_0 {
val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString
private val prefixPathString = s"/${ApiPathZero.toString}/${implementedInApiVersion.toString}"
private val liftProxyBaseUrl = APIUtil.getPropsValue("http4s.lift_proxy_base_url", "http://localhost:8080")
resourceDocs += ResourceDoc(
null,
@ -219,6 +224,42 @@ object Http4s500 {
}
}
private def proxyToLift(req: Request[IO]): IO[Response[IO]] = {
val targetUrl = liftProxyBaseUrl.stripSuffix("/") + req.uri.renderString
val filteredHeaders = req.headers.headers
.filterNot(h => {
val name = h.name.toString.toLowerCase
name == "host" || name == "content-length" || name == "transfer-encoding"
})
.map(h => h.name.toString -> h.value)
.toMap
for {
body <- req.bodyText.compile.string
dispatchReq = (
DispatchUrl(targetUrl)
.setMethod(req.method.name)
.setBodyEncoding(StandardCharsets.UTF_8)
.setBody(body)
<:< filteredHeaders
)
liftResp <- IO.fromFuture(IO(DispatchHttp.default(dispatchReq > DispatchAs.Response(p => p))))
status = org.http4s.Status.fromInt(liftResp.getStatusCode).getOrElse(org.http4s.Status.InternalServerError)
responseBody = liftResp.getResponseBody
correlationHeader = Option(liftResp.getHeader("Correlation-Id")).filter(_.nonEmpty)
base = Response[IO](status).withEntity(responseBody)
withCorrelation = correlationHeader match {
case Some(value) => base.putHeaders(Header.Raw(org.typelevel.ci.CIString("Correlation-Id"), value))
case None => base
}
} yield withCorrelation
}
val proxy: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req if req.uri.path.renderString.startsWith(prefixPathString) =>
proxyToLift(req)
}
val allRoutes: HttpRoutes[IO] =
Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] =>
root(req)
@ -226,6 +267,7 @@ object Http4s500 {
.orElse(getBank(req))
.orElse(getProducts(req))
.orElse(getProduct(req))
.orElse(proxy(req))
}
val allRoutesWithMiddleware: HttpRoutes[IO] =

View File

@ -3,10 +3,13 @@ package code.api.v5_0_0
import cats.effect.IO
import cats.effect.unsafe.implicits.global
import code.api.util.APIUtil
import code.api.util.APIUtil.OAuth._
import net.liftweb.json.JValue
import net.liftweb.json.JsonAST.{JArray, JField, JObject, JString}
import net.liftweb.json.JsonParser.parse
import org.http4s.{Method, Request, Status, Uri}
import org.http4s.Header
import org.typelevel.ci.CIString
import org.scalatest.Tag
class V500ContractParityTest extends V500ServerSetup {
@ -146,17 +149,49 @@ class V500ContractParityTest extends V500ServerSetup {
liftResponse.body match {
case JObject(fields) =>
toFieldMap(fields).get("message") should not be empty
toFieldMap(fields).get("message").isDefined shouldBe true
case _ =>
fail("Expected Lift JSON object for missing product error")
}
http4sJson match {
case JObject(fields) =>
toFieldMap(fields).get("message") should not be empty
toFieldMap(fields).get("message").isDefined shouldBe true
case _ =>
fail("Expected http4s JSON object for missing product error")
}
}
scenario("private accounts endpoint is served (proxy parity)", V500ContractParityTag) {
val bankId = APIUtil.defaultBankId
val liftResponse = getPrivateAccounts(bankId, user1)
val liftReq = (v5_0_0_Request / "banks" / bankId / "accounts" / "private").GET <@(user1)
val reqData = extractParamsAndHeaders(liftReq, "", "")
val baseRequest = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString(s"/obp/v5.0.0/banks/$bankId/accounts/private")
)
val request = reqData.headers.foldLeft(baseRequest) { case (r, (k, v)) =>
r.putHeaders(Header.Raw(CIString(k), v))
}
val response = Http4s500.wrappedRoutesV500Services.orNotFound.run(request).unsafeRunSync()
val http4sStatus = response.status
val body = response.as[String].unsafeRunSync()
val http4sJson = if (body.trim.isEmpty) JObject(Nil) else parse(body)
liftResponse.code should equal(http4sStatus.code)
http4sJson match {
case JObject(fields) =>
toFieldMap(fields).get("accounts") match {
case Some(JArray(_)) => succeed
case _ => fail("Expected accounts field to be an array")
}
case _ =>
fail("Expected http4s JSON object for private accounts endpoint")
}
}
}
}