From e885d3f504c3a91379d382ccaa81b1bbea57f845 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 5 Feb 2026 01:10:08 +0100 Subject: [PATCH] refactor/(http4s): enhance content-type handling and debug logging - Add detailed debug logging for request content-type and body availability in runLiftDispatch - Improve contentType extraction to properly handle http4s ContentType with mediaType and charset - Convert http4s ContentType format (mediaType + charset) to Lift-compatible string format - Add fallback to Content-Type header when http4s contentType is unavailable - Remove pending system views CRUD parity test due to test environment limitations with separate LiftRules instances - Enhance bridge request handling for better content-type parity between http4s and Lift --- .../api/util/http4s/Http4sLiftWebBridge.scala | 30 ++- .../Http4sLiftBridgeParityTest.scala | 94 ---------- .../Http4sServerIntegrationTest.scala | 176 ++++++++++++++++-- 3 files changed, 182 insertions(+), 118 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala index 5076bbe11..b88b5d4b7 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala @@ -2,22 +2,22 @@ package code.api.util.http4s import cats.data.{Kleisli, OptionT} import cats.effect.IO -import code.api.{APIFailure, JsonResponseException, ResponseHeader} import code.api.util.APIUtil +import code.api.{APIFailure, JsonResponseException, ResponseHeader} import code.util.Helper.MdcLoggable import com.openbankproject.commons.util.ReflectUtils import net.liftweb.actor.LAFuture -import net.liftweb.common.{Box, Empty, Failure, Full, ParamFailure} +import net.liftweb.common._ import net.liftweb.http._ -import net.liftweb.http.provider.{HTTPContext, HTTPParam, HTTPProvider, HTTPRequest, HTTPSession, HTTPCookie, RetryState} +import net.liftweb.http.provider._ import org.http4s._ import org.typelevel.ci.CIString import java.io.{ByteArrayInputStream, ByteArrayOutputStream, InputStream} import java.time.format.DateTimeFormatter import java.time.{ZoneOffset, ZonedDateTime} -import java.util.{Locale, UUID} import java.util.concurrent.ConcurrentHashMap +import java.util.{Locale, UUID} import scala.collection.JavaConverters._ object Http4sLiftWebBridge extends MdcLoggable { @@ -68,6 +68,8 @@ object Http4sLiftWebBridge extends MdcLoggable { private def runLiftDispatch(req: Req): LiftResponse = { val handlers = LiftRules.statelessDispatch.toList ++ LiftRules.dispatch.toList logger.debug(s"[BRIDGE] runLiftDispatch: ${req.request.method} ${req.request.uri}, handlers count: ${handlers.size}") + logger.debug(s"[BRIDGE] Request contentType: ${req.request.contentType}") + logger.debug(s"[BRIDGE] Request body available: ${req.body.isDefined}, json available: ${req.json.isDefined}") logger.debug(s"[BRIDGE] Checking if any handler is defined for this request...") handlers.zipWithIndex.foreach { case (pf, idx) => val isDefined = pf.isDefinedAt(req) @@ -307,12 +309,20 @@ object Http4sLiftWebBridge extends MdcLoggable { def contextPath: String = "" def context: HTTPContext = Http4sLiftContext def contentType: net.liftweb.common.Box[String] = { - req.contentType.map(_.mediaType.toString) match { - case Some(ct) => Full(ct) - case None => headerParams.find(_.name.equalsIgnoreCase("Content-Type")).flatMap(_.values.headOption) match { - case Some(ct) => Full(ct) - case None => Empty - } + // First try to get from http4s contentType + req.contentType match { + case Some(ct) => + // Content-Type header contains mediaType and optional charset + // Convert to string format that Lift expects (e.g., "application/json") + val mediaTypeStr = ct.mediaType.mainType + "/" + ct.mediaType.subType + val charsetStr = ct.charset.map(cs => s"; charset=${cs.nioCharset.name}").getOrElse("") + Full(mediaTypeStr + charsetStr) + case None => + // Fallback to Content-Type header + headerParams.find(_.name.equalsIgnoreCase("Content-Type")).flatMap(_.values.headOption) match { + case Some(ct) => Full(ct) + case None => Empty + } } } def uri: String = uriPath diff --git a/obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgeParityTest.scala b/obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgeParityTest.scala index 69b3cd711..f0349e61a 100644 --- a/obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgeParityTest.scala +++ b/obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgeParityTest.scala @@ -236,99 +236,5 @@ class Http4sLiftBridgeParityTest extends V500ServerSetup { hasField(http4sJson, "token") shouldBe true assertCorrelationId(http4sHeaders) } - - scenario("System views CRUD parity", Http4sLiftBridgeParityTag) { - // SKIP: This test fails due to test environment limitations. - // The bridge runs in the test process with a separate LiftRules instance - // from the Jetty server process. In production (Http4sServer), this works - // correctly because bridge and Lift share the same process. - // Verified manually that POST /obp/v5.0.0/system-views works in Http4sServer. - pending - - /* - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemView.toString) - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemView.toString) - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanUpdateSystemView.toString) - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemView.toString) - - val viewId = "v" + APIUtil.generateUUID() - val createBody = createSystemViewJsonV500.copy(name = viewId).copy(metadata_view = viewId).toCreateViewJson - val createJson = write(createBody) - - val liftCreateReq = (v5_0_0_Request / "system-views").POST <@(user1) - val liftCreateResponse = makePostRequest(liftCreateReq, createJson) - val createReqData = extractParamsAndHeaders( - liftCreateReq, - createJson, - "UTF-8", - Map("Content-Type" -> "application/json") - ) - println(s"[DEBUG] createReqData URL: ${createReqData.url}, method: ${createReqData.method}") - val (http4sCreateStatus, http4sCreateJson, http4sCreateHeaders) = runHttp4s(createReqData) - http4sCreateStatus.code should equal(liftCreateResponse.code) - jsonKeysLower(http4sCreateJson) should equal(jsonKeysLower(liftCreateResponse.body)) - assertCorrelationId(http4sCreateHeaders) - val createdView = liftCreateResponse.body.extract[ViewJsonV500] - - val liftGetReq = (v5_0_0_Request / "system-views" / createdView.id).GET <@(user1) - val liftGetResponse = makeGetRequest(liftGetReq) - val getReqData = extractParamsAndHeaders(liftGetReq, "", "UTF-8") - val (http4sGetStatus, http4sGetJson, http4sGetHeaders) = runHttp4s(getReqData) - http4sGetStatus.code should equal(liftGetResponse.code) - jsonKeysLower(http4sGetJson) should equal(jsonKeysLower(liftGetResponse.body)) - assertCorrelationId(http4sGetHeaders) - - val updateBody = UpdateViewJSON( - description = "crud-updated", - metadata_view = createdView.metadata_view, - is_public = createdView.is_public, - is_firehose = Some(true), - which_alias_to_use = "public", - hide_metadata_if_alias_used = !createdView.hide_metadata_if_alias_used, - allowed_actions = List("can_see_images", "can_delete_comment"), - can_grant_access_to_views = Some(createdView.can_grant_access_to_views), - can_revoke_access_to_views = Some(createdView.can_revoke_access_to_views) - ) - val updateJson = write(updateBody) - val liftUpdateReq = (v5_0_0_Request / "system-views" / createdView.id).PUT <@(user1) - val liftUpdateResponse = makePutRequest(liftUpdateReq, updateJson) - val updateReqData = extractParamsAndHeaders( - liftUpdateReq, - updateJson, - "UTF-8", - Map("Content-Type" -> "application/json") - ) - val (http4sUpdateStatus, http4sUpdateJson, http4sUpdateHeaders) = runHttp4s(updateReqData) - http4sUpdateStatus.code should equal(liftUpdateResponse.code) - jsonKeysLower(http4sUpdateJson) should equal(jsonKeysLower(liftUpdateResponse.body)) - assertCorrelationId(http4sUpdateHeaders) - - val liftGetAfterUpdateReq = (v5_0_0_Request / "system-views" / createdView.id).GET <@(user1) - val liftGetAfterUpdateResponse = makeGetRequest(liftGetAfterUpdateReq) - val getAfterUpdateReqData = extractParamsAndHeaders(liftGetAfterUpdateReq, "", "UTF-8") - val (http4sGetAfterUpdateStatus, http4sGetAfterUpdateJson, http4sGetAfterUpdateHeaders) = runHttp4s(getAfterUpdateReqData) - http4sGetAfterUpdateStatus.code should equal(liftGetAfterUpdateResponse.code) - jsonKeysLower(http4sGetAfterUpdateJson) should equal(jsonKeysLower(liftGetAfterUpdateResponse.body)) - assertCorrelationId(http4sGetAfterUpdateHeaders) - - AccountAccess.findAll( - By(AccountAccess.view_id, createdView.id), - By(AccountAccess.user_fk, resourceUser1.id.get) - ).forall(_.delete_!) - val liftDeleteReq = (v5_0_0_Request / "system-views" / createdView.id).DELETE <@(user1) - val liftDeleteResponse = makeDeleteRequest(liftDeleteReq) - val deleteReqData = extractParamsAndHeaders(liftDeleteReq, "", "UTF-8") - val (http4sDeleteStatus, _, http4sDeleteHeaders) = runHttp4s(deleteReqData) - http4sDeleteStatus.code should equal(liftDeleteResponse.code) - assertCorrelationId(http4sDeleteHeaders) - - val liftGetAfterDeleteReq = (v5_0_0_Request / "system-views" / createdView.id).GET <@(user1) - val liftGetAfterDeleteResponse = makeGetRequest(liftGetAfterDeleteReq) - val getAfterDeleteReqData = extractParamsAndHeaders(liftGetAfterDeleteReq, "", "UTF-8") - val (http4sGetAfterDeleteStatus, _, http4sGetAfterDeleteHeaders) = runHttp4s(getAfterDeleteReqData) - http4sGetAfterDeleteStatus.code should equal(liftGetAfterDeleteResponse.code) - assertCorrelationId(http4sGetAfterDeleteHeaders) - */ - } } } diff --git a/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala b/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala index a3602b494..c0c31eb32 100644 --- a/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala +++ b/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala @@ -1,11 +1,20 @@ package code.api.http4sbridge import code.Http4sTestServer +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.createSystemViewJsonV500 +import code.api.util.APIUtil +import code.api.util.ApiRole.{CanCreateSystemView, CanDeleteSystemView, CanGetSystemView, CanUpdateSystemView} +import code.api.v5_0_0.ViewJsonV500 +import code.entitlement.Entitlement import code.setup.{DefaultUsers, ServerSetup, ServerSetupWithTestData} +import code.views.system.AccountAccess +import com.openbankproject.commons.model.UpdateViewJSON import dispatch.Defaults._ import dispatch._ import net.liftweb.json.JsonAST.JObject import net.liftweb.json.JsonParser.parse +import net.liftweb.json.Serialization.write +import net.liftweb.mapper.By import org.scalatest.Tag import scala.concurrent.Await @@ -33,6 +42,13 @@ class Http4sServerIntegrationTest extends ServerSetup with DefaultUsers with Ser private val http4sServer = Http4sTestServer private val baseUrl = s"http://${http4sServer.host}:${http4sServer.port}" + override def afterAll(): Unit = { + super.afterAll() + // Clean up test data + code.views.system.ViewDefinition.bulkDelete_!!() + AccountAccess.bulkDelete_!!() + } + private def makeHttp4sGetRequest(path: String, headers: Map[String, String] = Map.empty): (Int, String) = { val request = url(s"$baseUrl$path") val requestWithHeaders = headers.foldLeft(request) { case (req, (key, value)) => @@ -40,14 +56,16 @@ class Http4sServerIntegrationTest extends ServerSetup with DefaultUsers with Ser } try { - val response = Http.default(requestWithHeaders OK as.String) - val result = Await.result(response, 10.seconds) - (200, result) + val response = Http.default(requestWithHeaders.setHeader("Accept", "*/*") > as.Response(p => (p.getStatusCode, p.getResponseBody))) + Await.result(response, 10.seconds) } catch { - case e: java.util.concurrent.ExecutionException if e.getCause.getMessage.contains("401") => - (401, "Unauthorized") - case e: java.util.concurrent.ExecutionException if e.getCause.getMessage.contains("404") => - (404, "Not Found") + case e: java.util.concurrent.ExecutionException => + // Extract status code from exception message if possible + val statusPattern = """(\d{3})""".r + statusPattern.findFirstIn(e.getCause.getMessage) match { + case Some(code) => (code.toInt, e.getCause.getMessage) + case None => throw e + } case e: Exception => throw e } @@ -60,14 +78,52 @@ class Http4sServerIntegrationTest extends ServerSetup with DefaultUsers with Ser } try { - val response = Http.default(requestWithHeaders OK as.String) - val result = Await.result(response, 10.seconds) - (200, result) + val response = Http.default(requestWithHeaders.setHeader("Accept", "*/*") > as.Response(p => (p.getStatusCode, p.getResponseBody))) + val (statusCode, responseBody) = Await.result(response, 10.seconds) + (statusCode, responseBody) } catch { - case e: java.util.concurrent.ExecutionException if e.getCause.getMessage.contains("401") => - (401, "Unauthorized") - case e: java.util.concurrent.ExecutionException if e.getCause.getMessage.contains("404") => - (404, "Not Found") + case e: Exception => + throw e + } + } + + private def makeHttp4sPutRequest(path: String, body: String, headers: Map[String, String] = Map.empty): (Int, String) = { + val request = url(s"$baseUrl$path").PUT.setBody(body) + val requestWithHeaders = headers.foldLeft(request) { case (req, (key, value)) => + req.addHeader(key, value) + } + + try { + val response = Http.default(requestWithHeaders.setHeader("Accept", "*/*") > as.Response(p => (p.getStatusCode, p.getResponseBody))) + val (statusCode, responseBody) = Await.result(response, 10.seconds) + (statusCode, responseBody) + } catch { + case e: Exception => + throw e + } + } + + private def makeHttp4sDeleteRequest(path: String, headers: Map[String, String] = Map.empty): (Int, String) = { + val request = url(s"$baseUrl$path").DELETE + val requestWithHeaders = headers.foldLeft(request) { case (req, (key, value)) => + req.addHeader(key, value) + } + + try { + val response = Http.default(requestWithHeaders.setHeader("Accept", "*/*") > as.Response(p => { + val statusCode = p.getStatusCode + val body = if (p.getResponseBody != null) p.getResponseBody else "" + (statusCode, body) + })) + Await.result(response, 10.seconds) + } catch { + case e: java.util.concurrent.ExecutionException => + // Extract status code from exception message if possible + val statusPattern = """(\d{3})""".r + statusPattern.findFirstIn(e.getCause.getMessage) match { + case Some(code) => (code.toInt, e.getCause.getMessage) + case None => throw e + } case e: Exception => throw e } @@ -297,4 +353,96 @@ class Http4sServerIntegrationTest extends ServerSetup with DefaultUsers with Ser } } } + + feature("HTTP4S v5.0.0 System Views CRUD") { + + scenario("System views CRUD operations via HTTP4S server", Http4sServerIntegrationTag) { + Given("User has required entitlements for system views") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemView.toString) + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemView.toString) + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanUpdateSystemView.toString) + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemView.toString) + + val viewId = "v" + APIUtil.generateUUID() + val createViewBody = createSystemViewJsonV500.copy(name = viewId).copy(metadata_view = viewId).toCreateViewJson + val createJson = write(createViewBody) + + val authHeaders = Map( + "Authorization" -> s"DirectLogin token=${token1.value}", + "Content-Type" -> "application/json" + ) + + When("We POST to create a system view") + val (createStatus, createResponseBody) = makeHttp4sPostRequest("/obp/v5.0.0/system-views", createJson, authHeaders) + + Then("We should get a 201 response") + createStatus should equal(201) + + And("Response should contain the created view") + val createdView = parse(createResponseBody).extract[ViewJsonV500] + createdView.id should not be empty + + When("We GET the created system view") + val (getStatus, getBody) = makeHttp4sGetRequest(s"/obp/v5.0.0/system-views/${createdView.id}", authHeaders) + + Then("We should get a 200 response") + getStatus should equal(200) + + And("Response should contain the view details") + val retrievedView = parse(getBody).extract[ViewJsonV500] + retrievedView.id should equal(createdView.id) + + When("We PUT to update the system view") + val updateBody = UpdateViewJSON( + description = "crud-updated", + metadata_view = createdView.metadata_view, + is_public = createdView.is_public, + is_firehose = Some(true), + which_alias_to_use = "public", + hide_metadata_if_alias_used = !createdView.hide_metadata_if_alias_used, + allowed_actions = List("can_see_images", "can_delete_comment"), + can_grant_access_to_views = Some(createdView.can_grant_access_to_views), + can_revoke_access_to_views = Some(createdView.can_revoke_access_to_views) + ) + val updateJson = write(updateBody) + val (updateStatus, updateResponseBody) = makeHttp4sPutRequest(s"/obp/v5.0.0/system-views/${createdView.id}", updateJson, authHeaders) + + Then("We should get a 200 response") + updateStatus should equal(200) + + And("Response should contain the updated view") + val updatedView = parse(updateResponseBody).extract[ViewJsonV500] + updatedView.description should equal("crud-updated") + updatedView.is_firehose should equal(Some(true)) + + When("We GET the updated system view") + val (getAfterUpdateStatus, getAfterUpdateBody) = makeHttp4sGetRequest(s"/obp/v5.0.0/system-views/${createdView.id}", authHeaders) + + Then("We should get a 200 response") + getAfterUpdateStatus should equal(200) + + And("Response should reflect the updates") + val verifiedView = parse(getAfterUpdateBody).extract[ViewJsonV500] + verifiedView.description should equal("crud-updated") + verifiedView.is_firehose should equal(Some(true)) + + When("We DELETE the system view") + val (deleteStatus, deleteBody) = makeHttp4sDeleteRequest(s"/obp/v5.0.0/system-views/${createdView.id}", authHeaders) + + Then("We should get a 200 response") + deleteStatus should equal(200) + + And("Response should be true") + deleteBody should equal("true") + + When("We GET the deleted system view") + val (getAfterDeleteStatus, getAfterDeleteBody) = makeHttp4sGetRequest(s"/obp/v5.0.0/system-views/${createdView.id}", authHeaders) + + Then("We should get a 400 response (SystemViewNotFound)") + getAfterDeleteStatus should equal(400) + getAfterDeleteBody should include("OBP-30252") + getAfterDeleteBody should include("System view not found") + info("System view successfully deleted and verified") + } + } }