mirror of
https://github.com/OpenBankProject/OBP-API.git
synced 2026-02-06 11:06:49 +00:00
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:
parent
a1ef22004c
commit
e885d3f504
@ -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
|
||||
|
||||
@ -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)
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user