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
This commit is contained in:
hongwei 2026-02-05 01:10:08 +01:00
parent a1ef22004c
commit e885d3f504
3 changed files with 182 additions and 118 deletions

View File

@ -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

View File

@ -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)
*/
}
}
}

View File

@ -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")
}
}
}