From 9a6368bf8032f63d31bdd1308a30aa8f64fa559a Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 29 Jan 2026 10:30:08 +0100 Subject: [PATCH] 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. --- .../scala/code/api/v5_0_0/Http4s500.scala | 42 +++++++++++++++++++ .../api/v5_0_0/V500ContractParityTest.scala | 39 ++++++++++++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala index 0a64fbded..ee290d10d 100644 --- a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala +++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala @@ -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] = diff --git a/obp-api/src/test/scala/code/api/v5_0_0/V500ContractParityTest.scala b/obp-api/src/test/scala/code/api/v5_0_0/V500ContractParityTest.scala index 65b39a043..fe663e12c 100644 --- a/obp-api/src/test/scala/code/api/v5_0_0/V500ContractParityTest.scala +++ b/obp-api/src/test/scala/code/api/v5_0_0/V500ContractParityTest.scala @@ -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") + } + } } }