Skip to content

Commit 3cb783a

Browse files
authored
Merge pull request #2662 from hongwei1/develop
feature/addResourceDocsGuardsForHttp4s
2 parents f1a2915 + 0415d13 commit 3cb783a

File tree

11 files changed

+1160
-116
lines changed

11 files changed

+1160
-116
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
.zed
1313
.cursor
1414
.trae
15+
.kiro
1516
.classpath
1617
.project
1718
.cache
@@ -44,4 +45,4 @@ coursier
4445
metals.sbt
4546
obp-http4s-runner/src/main/resources/git.properties
4647
test-results
47-
untracked_files/
48+
untracked_files/

obp-api/src/main/scala/code/api/directlogin.scala

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,69 @@ object DirectLogin extends RestHelper with MdcLoggable {
416416

417417
}
418418

419+
/**
420+
* Validator that uses pre-extracted parameters from CallContext (for http4s support)
421+
* This avoids dependency on S.request which is not available in http4s context
422+
*/
423+
def validatorFutureWithParams(requestType: String, httpMethod: String, parameters: Map[String, String]): Future[(Int, String, Map[String, String])] = {
424+
425+
def validAccessTokenFuture(tokenKey: String) = {
426+
Tokens.tokens.vend.getTokenByKeyAndTypeFuture(tokenKey, TokenType.Access) map {
427+
case Full(token) => token.isValid
428+
case _ => false
429+
}
430+
}
431+
432+
var message = ""
433+
var httpCode: Int = 500
434+
435+
val missingParams = missingDirectLoginParameters(parameters, requestType)
436+
val validParams = validDirectLoginParameters(parameters)
437+
438+
val validF =
439+
if (requestType == "protectedResource") {
440+
validAccessTokenFuture(parameters.getOrElse("token", ""))
441+
} else if (requestType == "authorizationToken" &&
442+
APIUtil.getPropsAsBoolValue("direct_login_consumer_key_mandatory", true)) {
443+
APIUtil.registeredApplicationFuture(parameters.getOrElse("consumer_key", ""))
444+
} else {
445+
Future { true }
446+
}
447+
448+
for {
449+
valid <- validF
450+
} yield {
451+
if (parameters.get("error").isDefined) {
452+
message = parameters.get("error").getOrElse("")
453+
httpCode = 400
454+
}
455+
else if (missingParams.nonEmpty) {
456+
message = ErrorMessages.DirectLoginMissingParameters + missingParams.mkString(", ")
457+
httpCode = 400
458+
}
459+
else if (SILENCE_IS_GOLDEN != validParams.mkString("")) {
460+
message = validParams.mkString("")
461+
httpCode = 400
462+
}
463+
else if (requestType == "protectedResource" && !valid) {
464+
message = ErrorMessages.DirectLoginInvalidToken + parameters.getOrElse("token", "")
465+
httpCode = 401
466+
}
467+
else if (requestType == "authorizationToken" &&
468+
APIUtil.getPropsAsBoolValue("direct_login_consumer_key_mandatory", true) &&
469+
!valid) {
470+
logger.error("application: " + parameters.getOrElse("consumer_key", "") + " not found")
471+
message = ErrorMessages.InvalidConsumerKey
472+
httpCode = 401
473+
}
474+
else
475+
httpCode = 200
476+
if (message.nonEmpty)
477+
logger.error("error message : " + message)
478+
(httpCode, message, parameters)
479+
}
480+
}
481+
419482
private def generateTokenAndSecret(claims: JWTClaimsSet): (String, String) =
420483
{
421484
// generate random string
@@ -473,12 +536,20 @@ object DirectLogin extends RestHelper with MdcLoggable {
473536
}
474537

475538
def getUserFromDirectLoginHeaderFuture(sc: CallContext) : Future[(Box[User], Option[CallContext])] = {
476-
val httpMethod = S.request match {
539+
val httpMethod = if (sc.verb.nonEmpty) sc.verb else S.request match {
477540
case Full(r) => r.request.method
478541
case _ => "GET"
479542
}
543+
// Prefer directLoginParams from CallContext (http4s), fall back to S.request (Lift)
544+
val directLoginParamsFromCC = sc.directLoginParams
480545
for {
481-
(httpCode, message, directLoginParameters) <- validatorFuture("protectedResource", httpMethod)
546+
(httpCode, message, directLoginParameters) <- if (directLoginParamsFromCC.nonEmpty && directLoginParamsFromCC.contains("token")) {
547+
// Use params from CallContext (http4s path)
548+
validatorFutureWithParams("protectedResource", httpMethod, directLoginParamsFromCC)
549+
} else {
550+
// 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.
551+
validatorFuture("protectedResource", httpMethod)
552+
}
482553
_ <- Future { if (httpCode == 400 || httpCode == 401) Empty else Full("ok") } map { x => fullBoxOrException(x ?~! message) }
483554
consumer <- OAuthHandshake.getConsumerFromTokenFuture(200, (if (directLoginParameters.isDefinedAt("token")) directLoginParameters.get("token") else Empty))
484555
user <- OAuthHandshake.getUserFromTokenFuture(200, (if (directLoginParameters.isDefinedAt("token")) directLoginParameters.get("token") else Empty))

obp-api/src/main/scala/code/api/util/APIUtil.scala

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,17 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
334334
commit
335335
}
336336

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

338349
/**
339350
* Caching of unchanged resources
@@ -3039,18 +3050,49 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
30393050
def getUserAndSessionContextFuture(cc: CallContext): OBPReturnType[Box[User]] = {
30403051
val s = S
30413052
val spelling = getSpellingParam()
3042-
val body: Box[String] = getRequestBody(S.request)
3043-
val implementedInVersion = S.request.openOrThrowException(attemptedToOpenAnEmptyBox).view
3044-
val verb = S.request.openOrThrowException(attemptedToOpenAnEmptyBox).requestType.method
3045-
val url = URLDecoder.decode(ObpS.uriAndQueryString.getOrElse(""),"UTF-8")
3046-
val correlationId = getCorrelationId()
3047-
val reqHeaders = S.request.openOrThrowException(attemptedToOpenAnEmptyBox).request.headers
3053+
3054+
// NEW: Prefer CallContext fields, fall back to S.request for Lift compatibility
3055+
// This allows http4s to use the same auth chain by populating CallContext fields
3056+
val body: Box[String] = cc.httpBody match {
3057+
case Some(b) => Full(b)
3058+
case None => getRequestBody(S.request)
3059+
}
3060+
3061+
val implementedInVersion = if (cc.implementedInVersion.nonEmpty)
3062+
cc.implementedInVersion
3063+
else
3064+
S.request.openOrThrowException(attemptedToOpenAnEmptyBox).view
3065+
3066+
val verb = if (cc.verb.nonEmpty)
3067+
cc.verb
3068+
else
3069+
S.request.openOrThrowException(attemptedToOpenAnEmptyBox).requestType.method
3070+
3071+
val url = if (cc.url.nonEmpty)
3072+
cc.url
3073+
else
3074+
URLDecoder.decode(ObpS.uriAndQueryString.getOrElse(""),"UTF-8")
3075+
3076+
val correlationId = if (cc.correlationId.nonEmpty)
3077+
cc.correlationId
3078+
else
3079+
getCorrelationId()
3080+
3081+
val reqHeaders = if (cc.requestHeaders.nonEmpty)
3082+
cc.requestHeaders
3083+
else
3084+
S.request.openOrThrowException(attemptedToOpenAnEmptyBox).request.headers
3085+
3086+
val remoteIpAddress = if (cc.ipAddress.nonEmpty)
3087+
cc.ipAddress
3088+
else
3089+
getRemoteIpAddress()
3090+
30483091
val xRequestId: Option[String] =
30493092
reqHeaders.find(_.name.toLowerCase() == RequestHeader.`X-Request-ID`.toLowerCase())
30503093
.map(_.values.mkString(","))
30513094
logger.debug(s"Request Headers for verb: $verb, URL: $url")
30523095
logger.debug(reqHeaders.map(h => h.name + ": " + h.values.mkString(",")).mkString)
3053-
val remoteIpAddress = getRemoteIpAddress()
30543096

30553097
val authHeaders = AuthorisationUtil.getAuthorisationHeaders(reqHeaders)
30563098
val authHeadersWithEmptyValues = RequestHeadersUtil.checkEmptyRequestHeaderValues(reqHeaders)

obp-api/src/main/scala/code/api/util/ApiSession.scala

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,12 @@ case class CallContext(
5555
xRateLimitRemaining : Long = -1,
5656
xRateLimitReset : Long = -1,
5757
paginationOffset : Option[String] = None,
58-
paginationLimit : Option[String] = None
58+
paginationLimit : Option[String] = None,
59+
// Validated entities from ResourceDoc middleware (http4s)
60+
bank: Option[Bank] = None,
61+
bankAccount: Option[BankAccount] = None,
62+
view: Option[View] = None,
63+
counterparty: Option[CounterpartyTrait] = None
5964
) extends MdcLoggable {
6065
override def toString: String = SecureLogging.maskSensitive(
6166
s"${this.getClass.getSimpleName}(${this.productIterator.mkString(", ")})"
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package code.api.util.http4s
2+
3+
import cats.effect._
4+
import code.api.APIFailureNewStyle
5+
import code.api.util.ErrorMessages._
6+
import code.api.util.CallContext
7+
import net.liftweb.common.{Failure => LiftFailure}
8+
import net.liftweb.json.compactRender
9+
import net.liftweb.json.JsonDSL._
10+
import org.http4s._
11+
import org.http4s.headers.`Content-Type`
12+
import org.typelevel.ci.CIString
13+
14+
/**
15+
* Converts OBP errors to http4s Response[IO].
16+
*
17+
* Handles:
18+
* - APIFailureNewStyle (structured errors with code and message)
19+
* - Box Failure (Lift framework errors)
20+
* - Unknown exceptions
21+
*
22+
* All responses include:
23+
* - JSON body with code and message
24+
* - Correlation-Id header for request tracing
25+
* - Appropriate HTTP status code
26+
*/
27+
object ErrorResponseConverter {
28+
import net.liftweb.json.Formats
29+
import code.api.util.CustomJsonFormats
30+
31+
implicit val formats: Formats = CustomJsonFormats.formats
32+
private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json)
33+
34+
/**
35+
* OBP standard error response format.
36+
*/
37+
case class OBPErrorResponse(
38+
code: Int,
39+
message: String
40+
)
41+
42+
/**
43+
* Convert error response to JSON string using Lift JSON.
44+
*/
45+
private def toJsonString(error: OBPErrorResponse): String = {
46+
val json = ("code" -> error.code) ~ ("message" -> error.message)
47+
compactRender(json)
48+
}
49+
50+
/**
51+
* Convert any error to http4s Response[IO].
52+
*/
53+
def toHttp4sResponse(error: Throwable, callContext: CallContext): IO[Response[IO]] = {
54+
error match {
55+
case e: APIFailureNewStyle => apiFailureToResponse(e, callContext)
56+
case _ => unknownErrorToResponse(error, callContext)
57+
}
58+
}
59+
60+
/**
61+
* Convert APIFailureNewStyle to http4s Response.
62+
* Uses failCode as HTTP status and failMsg as error message.
63+
*/
64+
def apiFailureToResponse(failure: APIFailureNewStyle, callContext: CallContext): IO[Response[IO]] = {
65+
val errorJson = OBPErrorResponse(failure.failCode, failure.failMsg)
66+
val status = org.http4s.Status.fromInt(failure.failCode).getOrElse(org.http4s.Status.BadRequest)
67+
IO.pure(
68+
Response[IO](status)
69+
.withEntity(toJsonString(errorJson))
70+
.withContentType(jsonContentType)
71+
.putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId))
72+
)
73+
}
74+
75+
/**
76+
* Convert Lift Box Failure to http4s Response.
77+
* Returns 400 Bad Request with failure message.
78+
*/
79+
def boxFailureToResponse(failure: LiftFailure, callContext: CallContext): IO[Response[IO]] = {
80+
val errorJson = OBPErrorResponse(400, failure.msg)
81+
IO.pure(
82+
Response[IO](org.http4s.Status.BadRequest)
83+
.withEntity(toJsonString(errorJson))
84+
.withContentType(jsonContentType)
85+
.putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId))
86+
)
87+
}
88+
89+
/**
90+
* Convert unknown error to http4s Response.
91+
* Returns 500 Internal Server Error.
92+
*/
93+
def unknownErrorToResponse(e: Throwable, callContext: CallContext): IO[Response[IO]] = {
94+
val errorJson = OBPErrorResponse(500, s"$UnknownError: ${e.getMessage}")
95+
IO.pure(
96+
Response[IO](org.http4s.Status.InternalServerError)
97+
.withEntity(toJsonString(errorJson))
98+
.withContentType(jsonContentType)
99+
.putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId))
100+
)
101+
}
102+
103+
/**
104+
* Create error response with specific status code and message.
105+
*/
106+
def createErrorResponse(statusCode: Int, message: String, callContext: CallContext): IO[Response[IO]] = {
107+
val errorJson = OBPErrorResponse(statusCode, message)
108+
val status = org.http4s.Status.fromInt(statusCode).getOrElse(org.http4s.Status.BadRequest)
109+
IO.pure(
110+
Response[IO](status)
111+
.withEntity(toJsonString(errorJson))
112+
.withContentType(jsonContentType)
113+
.putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId))
114+
)
115+
}
116+
}

0 commit comments

Comments
 (0)