Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c2e2254
feature/(Http4s700): set JSON content type for API responses
simonredfern Jan 13, 2026
59ae64b
rafactor/(.gitignore): add `.kiro` to ignored files list
hongwei1 Jan 15, 2026
f58fb77
refactor/(api): update `CallContext` logic and introduce Http4s utili…
hongwei1 Jan 15, 2026
2c9af4e
feature/ (http4s): add comprehensive Http4s utilities and middleware …
hongwei1 Jan 16, 2026
bae97ed
refactor/(http4s): improve ResourceDoc matching and error handling
hongwei1 Jan 16, 2026
7011677
refactor/(http4s): enhance ResourceDocMiddleware authentication flow …
hongwei1 Jan 16, 2026
8546331
test/(Http4s700): add UserNotLoggedIn error response to API info
hongwei1 Jan 16, 2026
2d139b1
refactor/(Http4s700): remove user authentication message from API info
hongwei1 Jan 16, 2026
64b1ac3
feature/(directlogin): add http4s support for DirectLogin authentication
hongwei1 Jan 16, 2026
64d7219
refactor/(http4s): enhance ResourceDocMiddleware with logging and aut…
hongwei1 Jan 19, 2026
a53bcf4
refactor/(http4s): reorder validation chain to check roles before ban…
hongwei1 Jan 19, 2026
ddee799
feature/(http4s): add counterparty validation to ResourceDocMiddleware
hongwei1 Jan 19, 2026
c5e6b11
refactor/(api): centralize API info properties in APIUtil
hongwei1 Jan 20, 2026
4f9c195
refactor/(api): streamline API structure and enhance maintainability
hongwei1 Jan 20, 2026
98a6e2b
feature/(http4s700): implement getBanks endpoint with proper context …
hongwei1 Jan 20, 2026
b4ddcd6
refactor/ (ResourceDocMiddleware): ensure JSON content type for respo…
hongwei1 Jan 20, 2026
69ae30b
refactor/(ResourceDocMiddleware): improve JSON content type handling …
hongwei1 Jan 20, 2026
8f2048c
Merge remote-tracking branch 'refs/remotes/Hongwei/developPom' into d…
hongwei1 Jan 20, 2026
de2ed5f
refactor(api): update authentication error handling to use Authentica…
hongwei1 Jan 20, 2026
3162446
refactor(http4s700): comment out getCounterpartyByIdWithMiddleware en…
hongwei1 Jan 20, 2026
48afd12
docfix/added comments
hongwei1 Jan 21, 2026
f6d095b
docfix/added comments
hongwei1 Jan 21, 2026
ef6bff5
refactor/tweaked the variable names
hongwei1 Jan 21, 2026
f73ad66
refactor/tweaked code
hongwei1 Jan 21, 2026
61abf36
Merge remote-tracking branch 'Hongwei/refactor/renameUserNotLoggedIn'…
hongwei1 Jan 21, 2026
baecbc1
Merge remote-tracking branch 'Simon/develop' into develop
hongwei1 Jan 22, 2026
e8999ba
refactor(http4s): consolidate validated entities into CallContext
hongwei1 Jan 22, 2026
dbd046b
refactor(http4s): enhance CallContext extraction and validation chain
hongwei1 Jan 22, 2026
df54e60
refactor(http4s): simplify CallContext access with implicit RequestOp…
hongwei1 Jan 22, 2026
11e4a71
feature(http4s): add EndpointHelpers for simplified endpoint implemen…
hongwei1 Jan 22, 2026
83d90bf
Merge remote-tracking branch 'Simon/develop' into develop
hongwei1 Jan 22, 2026
0415d13
refactor/(http4s): improve documentation and code clarity in error ha…
hongwei1 Jan 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
.zed
.cursor
.trae
.kiro
.classpath
.project
.cache
Expand Down Expand Up @@ -44,4 +45,4 @@ coursier
metals.sbt
obp-http4s-runner/src/main/resources/git.properties
test-results
untracked_files/
untracked_files/
75 changes: 73 additions & 2 deletions obp-api/src/main/scala/code/api/directlogin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,69 @@ object DirectLogin extends RestHelper with MdcLoggable {

}

/**
* Validator that uses pre-extracted parameters from CallContext (for http4s support)
* This avoids dependency on S.request which is not available in http4s context
*/
def validatorFutureWithParams(requestType: String, httpMethod: String, parameters: Map[String, String]): Future[(Int, String, Map[String, String])] = {

def validAccessTokenFuture(tokenKey: String) = {
Tokens.tokens.vend.getTokenByKeyAndTypeFuture(tokenKey, TokenType.Access) map {
case Full(token) => token.isValid
case _ => false
}
}

var message = ""
var httpCode: Int = 500

val missingParams = missingDirectLoginParameters(parameters, requestType)
val validParams = validDirectLoginParameters(parameters)

val validF =
if (requestType == "protectedResource") {
validAccessTokenFuture(parameters.getOrElse("token", ""))
} else if (requestType == "authorizationToken" &&
APIUtil.getPropsAsBoolValue("direct_login_consumer_key_mandatory", true)) {
APIUtil.registeredApplicationFuture(parameters.getOrElse("consumer_key", ""))
} else {
Future { true }
}

for {
valid <- validF
} yield {
if (parameters.get("error").isDefined) {
message = parameters.get("error").getOrElse("")
httpCode = 400
}
else if (missingParams.nonEmpty) {
message = ErrorMessages.DirectLoginMissingParameters + missingParams.mkString(", ")
httpCode = 400
}
else if (SILENCE_IS_GOLDEN != validParams.mkString("")) {
message = validParams.mkString("")
httpCode = 400
}
else if (requestType == "protectedResource" && !valid) {
message = ErrorMessages.DirectLoginInvalidToken + parameters.getOrElse("token", "")
httpCode = 401
}
else if (requestType == "authorizationToken" &&
APIUtil.getPropsAsBoolValue("direct_login_consumer_key_mandatory", true) &&
!valid) {
logger.error("application: " + parameters.getOrElse("consumer_key", "") + " not found")
message = ErrorMessages.InvalidConsumerKey
httpCode = 401
}
else
httpCode = 200
if (message.nonEmpty)
logger.error("error message : " + message)
(httpCode, message, parameters)
}
}

private def generateTokenAndSecret(claims: JWTClaimsSet): (String, String) =
{
// generate random string
Expand Down Expand Up @@ -473,12 +536,20 @@ object DirectLogin extends RestHelper with MdcLoggable {
}

def getUserFromDirectLoginHeaderFuture(sc: CallContext) : Future[(Box[User], Option[CallContext])] = {
val httpMethod = S.request match {
val httpMethod = if (sc.verb.nonEmpty) sc.verb else S.request match {
case Full(r) => r.request.method
case _ => "GET"
}
// Prefer directLoginParams from CallContext (http4s), fall back to S.request (Lift)
val directLoginParamsFromCC = sc.directLoginParams
for {
(httpCode, message, directLoginParameters) <- validatorFuture("protectedResource", httpMethod)
(httpCode, message, directLoginParameters) <- if (directLoginParamsFromCC.nonEmpty && directLoginParamsFromCC.contains("token")) {
// Use params from CallContext (http4s path)
validatorFutureWithParams("protectedResource", httpMethod, directLoginParamsFromCC)
} else {
// Fall back to S.request (Lift path), e.g. we still use Lift to generate the token and secret, so we need to maintain backward compatibility here.
validatorFuture("protectedResource", httpMethod)
}
_ <- Future { if (httpCode == 400 || httpCode == 401) Empty else Full("ok") } map { x => fullBoxOrException(x ?~! message) }
consumer <- OAuthHandshake.getConsumerFromTokenFuture(200, (if (directLoginParameters.isDefinedAt("token")) directLoginParameters.get("token") else Empty))
user <- OAuthHandshake.getUserFromTokenFuture(200, (if (directLoginParameters.isDefinedAt("token")) directLoginParameters.get("token") else Empty))
Expand Down
56 changes: 49 additions & 7 deletions obp-api/src/main/scala/code/api/util/APIUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,17 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
commit
}

// API info props helpers (keep values centralized)
lazy val hostedByOrganisation: String = getPropsValue("hosted_by.organisation", "TESOBE")
lazy val hostedByEmail: String = getPropsValue("hosted_by.email", "contact@tesobe.com")
lazy val hostedByPhone: String = getPropsValue("hosted_by.phone", "+49 (0)30 8145 3994")
lazy val organisationWebsite: String = getPropsValue("organisation_website", "https://www.tesobe.com")
lazy val hostedAtOrganisation: String = getPropsValue("hosted_at.organisation", "")
lazy val hostedAtOrganisationWebsite: String = getPropsValue("hosted_at.organisation_website", "")
lazy val energySourceOrganisation: String = getPropsValue("energy_source.organisation", "")
lazy val energySourceOrganisationWebsite: String = getPropsValue("energy_source.organisation_website", "")
lazy val resourceDocsRequiresRole: Boolean = getPropsAsBoolValue("resource_docs_requires_role", false)


/**
* Caching of unchanged resources
Expand Down Expand Up @@ -3039,18 +3050,49 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
def getUserAndSessionContextFuture(cc: CallContext): OBPReturnType[Box[User]] = {
val s = S
val spelling = getSpellingParam()
val body: Box[String] = getRequestBody(S.request)
val implementedInVersion = S.request.openOrThrowException(attemptedToOpenAnEmptyBox).view
val verb = S.request.openOrThrowException(attemptedToOpenAnEmptyBox).requestType.method
val url = URLDecoder.decode(ObpS.uriAndQueryString.getOrElse(""),"UTF-8")
val correlationId = getCorrelationId()
val reqHeaders = S.request.openOrThrowException(attemptedToOpenAnEmptyBox).request.headers

// NEW: Prefer CallContext fields, fall back to S.request for Lift compatibility
// This allows http4s to use the same auth chain by populating CallContext fields
val body: Box[String] = cc.httpBody match {
case Some(b) => Full(b)
case None => getRequestBody(S.request)
}

val implementedInVersion = if (cc.implementedInVersion.nonEmpty)
cc.implementedInVersion
else
S.request.openOrThrowException(attemptedToOpenAnEmptyBox).view

val verb = if (cc.verb.nonEmpty)
cc.verb
else
S.request.openOrThrowException(attemptedToOpenAnEmptyBox).requestType.method

val url = if (cc.url.nonEmpty)
cc.url
else
URLDecoder.decode(ObpS.uriAndQueryString.getOrElse(""),"UTF-8")

val correlationId = if (cc.correlationId.nonEmpty)
cc.correlationId
else
getCorrelationId()

val reqHeaders = if (cc.requestHeaders.nonEmpty)
cc.requestHeaders
else
S.request.openOrThrowException(attemptedToOpenAnEmptyBox).request.headers

val remoteIpAddress = if (cc.ipAddress.nonEmpty)
cc.ipAddress
else
getRemoteIpAddress()

val xRequestId: Option[String] =
reqHeaders.find(_.name.toLowerCase() == RequestHeader.`X-Request-ID`.toLowerCase())
.map(_.values.mkString(","))
logger.debug(s"Request Headers for verb: $verb, URL: $url")
logger.debug(reqHeaders.map(h => h.name + ": " + h.values.mkString(",")).mkString)
val remoteIpAddress = getRemoteIpAddress()

val authHeaders = AuthorisationUtil.getAuthorisationHeaders(reqHeaders)
val authHeadersWithEmptyValues = RequestHeadersUtil.checkEmptyRequestHeaderValues(reqHeaders)
Expand Down
7 changes: 6 additions & 1 deletion obp-api/src/main/scala/code/api/util/ApiSession.scala
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@ case class CallContext(
xRateLimitRemaining : Long = -1,
xRateLimitReset : Long = -1,
paginationOffset : Option[String] = None,
paginationLimit : Option[String] = None
paginationLimit : Option[String] = None,
// Validated entities from ResourceDoc middleware (http4s)
bank: Option[Bank] = None,
bankAccount: Option[BankAccount] = None,
view: Option[View] = None,
counterparty: Option[CounterpartyTrait] = None
) extends MdcLoggable {
override def toString: String = SecureLogging.maskSensitive(
s"${this.getClass.getSimpleName}(${this.productIterator.mkString(", ")})"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package code.api.util.http4s

import cats.effect._
import code.api.APIFailureNewStyle
import code.api.util.ErrorMessages._
import code.api.util.CallContext
import net.liftweb.common.{Failure => LiftFailure}
import net.liftweb.json.compactRender
import net.liftweb.json.JsonDSL._
import org.http4s._
import org.http4s.headers.`Content-Type`
import org.typelevel.ci.CIString

/**
* Converts OBP errors to http4s Response[IO].
*
* Handles:
* - APIFailureNewStyle (structured errors with code and message)
* - Box Failure (Lift framework errors)
* - Unknown exceptions
*
* All responses include:
* - JSON body with code and message
* - Correlation-Id header for request tracing
* - Appropriate HTTP status code
*/
object ErrorResponseConverter {
import net.liftweb.json.Formats
import code.api.util.CustomJsonFormats

implicit val formats: Formats = CustomJsonFormats.formats
private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json)

/**
* OBP standard error response format.
*/
case class OBPErrorResponse(
code: Int,
message: String
)

/**
* Convert error response to JSON string using Lift JSON.
*/
private def toJsonString(error: OBPErrorResponse): String = {
val json = ("code" -> error.code) ~ ("message" -> error.message)
compactRender(json)
}

/**
* Convert any error to http4s Response[IO].
*/
def toHttp4sResponse(error: Throwable, callContext: CallContext): IO[Response[IO]] = {
error match {
case e: APIFailureNewStyle => apiFailureToResponse(e, callContext)
case _ => unknownErrorToResponse(error, callContext)
}
}

/**
* Convert APIFailureNewStyle to http4s Response.
* Uses failCode as HTTP status and failMsg as error message.
*/
def apiFailureToResponse(failure: APIFailureNewStyle, callContext: CallContext): IO[Response[IO]] = {
val errorJson = OBPErrorResponse(failure.failCode, failure.failMsg)
val status = org.http4s.Status.fromInt(failure.failCode).getOrElse(org.http4s.Status.BadRequest)
IO.pure(
Response[IO](status)
.withEntity(toJsonString(errorJson))
.withContentType(jsonContentType)
.putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId))
)
}

/**
* Convert Lift Box Failure to http4s Response.
* Returns 400 Bad Request with failure message.
*/
def boxFailureToResponse(failure: LiftFailure, callContext: CallContext): IO[Response[IO]] = {
val errorJson = OBPErrorResponse(400, failure.msg)
IO.pure(
Response[IO](org.http4s.Status.BadRequest)
.withEntity(toJsonString(errorJson))
.withContentType(jsonContentType)
.putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId))
)
}

/**
* Convert unknown error to http4s Response.
* Returns 500 Internal Server Error.
*/
def unknownErrorToResponse(e: Throwable, callContext: CallContext): IO[Response[IO]] = {
val errorJson = OBPErrorResponse(500, s"$UnknownError: ${e.getMessage}")
IO.pure(
Response[IO](org.http4s.Status.InternalServerError)
.withEntity(toJsonString(errorJson))
.withContentType(jsonContentType)
.putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId))
)
}

/**
* Create error response with specific status code and message.
*/
def createErrorResponse(statusCode: Int, message: String, callContext: CallContext): IO[Response[IO]] = {
val errorJson = OBPErrorResponse(statusCode, message)
val status = org.http4s.Status.fromInt(statusCode).getOrElse(org.http4s.Status.BadRequest)
IO.pure(
Response[IO](status)
.withEntity(toJsonString(errorJson))
.withContentType(jsonContentType)
.putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId))
)
}
}
Loading