Merge remote-tracking branch 'hongwei/develop' into develop

This commit is contained in:
Marko Milić 2026-01-16 15:18:21 +01:00
commit 1ca949689f
4 changed files with 899 additions and 62 deletions

3
.gitignore vendored
View File

@ -12,6 +12,7 @@
.zed
.cursor
.trae
.kiro
.classpath
.project
.cache
@ -43,4 +44,4 @@ project/project
coursier
metals.sbt
obp-http4s-runner/src/main/resources/git.properties
test-results
test-results

View File

@ -3039,18 +3039,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)

View File

@ -8,7 +8,8 @@ import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocsAPIMethodsUtil}
import code.api.util.APIUtil.{EmptyBody, _}
import code.api.util.ApiTag._
import code.api.util.ErrorMessages._
import code.api.util.{ApiVersionUtils, CustomJsonFormats, NewStyle}
import code.api.util.{ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle}
import code.api.util.ApiRole.canReadResourceDoc
import code.api.v1_4_0.JSONFactory1_4_0
import code.api.v4_0_0.JSONFactory400
import com.github.dwickern.macros.NameOf.nameOf
@ -18,8 +19,9 @@ import net.liftweb.json.JsonAST.prettyRender
import net.liftweb.json.{Extraction, Formats}
import org.http4s._
import org.http4s.dsl.io._
import org.typelevel.vault.Key
import org.http4s.headers._
import java.util.UUID
import scala.collection.mutable.ArrayBuffer
import scala.concurrent.Future
import scala.language.{higherKinds, implicitConversions}
@ -35,25 +37,11 @@ object Http4s700 {
val versionStatus = ApiVersionStatus.STABLE.toString
val resourceDocs = ArrayBuffer[ResourceDoc]()
case class CallContext(userId: String, requestId: String)
val callContextKey: Key[CallContext] =
Key.newKey[IO, CallContext].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
object CallContextMiddleware {
def withCallContext(routes: HttpRoutes[IO]): HttpRoutes[IO] =
Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] =>
val callContext = CallContext(userId = "example-user", requestId = java.util.UUID.randomUUID().toString)
val updatedAttributes = req.attributes.insert(callContextKey, callContext)
val updatedReq = req.withAttributes(updatedAttributes)
routes(updatedReq)
}
}
object Implementations7_0_0 {
// Common prefix: /obp/v7.0.0
val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString
private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json)
resourceDocs += ResourceDoc(
@ -68,9 +56,9 @@ object Http4s700 {
|* API version
|* Hosted by information
|* Git Commit
|${userAuthenticationMessage(false)}""",
|${userAuthenticationMessage(true)}""",
EmptyBody,
apiInfoJSON,
apiInfoJSON,
List(UnknownError, "no connector set"),
apiTagApi :: Nil,
http4sPartialFunction = Some(root)
@ -79,16 +67,47 @@ object Http4s700 {
// Route: GET /obp/v7.0.0/root
val root: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "root" =>
val callContext = req.attributes.lookup(callContextKey).get.asInstanceOf[CallContext]
Ok(IO.fromFuture(IO(
for {
_ <- Future() // Just start async call
} yield {
convertAnyToJsonString(
JSONFactory700.getApiInfoJSON(implementedInApiVersion, s"Hello, ${callContext.userId}! Your request ID is ${callContext.requestId}.")
)
}
)))
(for {
cc <- Http4sCallContextBuilder.fromRequest(req, implementedInApiVersion.toString)
result <- IO.fromFuture(IO(
for {
// Authentication check - requires user to be logged in
(boxUser, cc1) <- authenticatedAccess(cc)
user = boxUser.openOrThrowException("User not logged in")
} yield {
convertAnyToJsonString(
JSONFactory700.getApiInfoJSON(implementedInApiVersion, s"Hello ${user.name}! Your request ID is ${cc1.map(_.correlationId).getOrElse(cc.correlationId)}.")
)
}
))
} yield result).attempt.flatMap {
case Right(jsonResult) =>
Ok(jsonResult).map(_.withContentType(jsonContentType))
case Left(e: code.api.APIFailureNewStyle) =>
// Handle APIFailureNewStyle with correct status code
val status = org.http4s.Status.fromInt(e.failCode).getOrElse(org.http4s.Status.BadRequest)
val errorJson = s"""{"code":${e.failCode},"message":"${e.failMsg}"}"""
IO.pure(Response[IO](status)
.withEntity(errorJson)
.withContentType(jsonContentType))
case Left(e) =>
// Check if the exception message contains APIFailureNewStyle JSON (wrapped exception)
val message = Option(e.getMessage).getOrElse("")
if (message.contains("failMsg") && message.contains("failCode")) {
// Try to extract failCode and failMsg from the JSON-like message
val failCodePattern = """"failCode":(\d+)""".r
val failMsgPattern = """"failMsg":"([^"]+)"""".r
val failCode = failCodePattern.findFirstMatchIn(message).map(_.group(1).toInt).getOrElse(500)
val failMsg = failMsgPattern.findFirstMatchIn(message).map(_.group(1)).getOrElse(message)
val status = org.http4s.Status.fromInt(failCode).getOrElse(org.http4s.Status.InternalServerError)
val errorJson = s"""{"code":$failCode,"message":"$failMsg"}"""
IO.pure(Response[IO](status)
.withEntity(errorJson)
.withContentType(jsonContentType))
} else {
ErrorResponseConverter.unknownErrorToResponse(e, CallContext(correlationId = UUID.randomUUID().toString))
}
}
}
resourceDocs += ResourceDoc(
@ -117,41 +136,183 @@ object Http4s700 {
val getBanks: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "banks" =>
import com.openbankproject.commons.ExecutionContext.Implicits.global
Ok(IO.fromFuture(IO(
for {
(banks, callContext) <- NewStyle.function.getBanks(None)
} yield {
convertAnyToJsonString(JSONFactory400.createBanksJson(banks))
}
)))
val response = for {
cc <- Http4sCallContextBuilder.fromRequest(req, implementedInApiVersion.toString)
result <- IO.fromFuture(IO(
for {
(banks, _) <- NewStyle.function.getBanks(Some(cc))
} yield {
convertAnyToJsonString(JSONFactory400.createBanksJson(banks))
}
))
} yield result
Ok(response).map(_.withContentType(jsonContentType))
}
val getResourceDocsObpV700: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "resource-docs" / requestedApiVersionString / "obp" =>
import com.openbankproject.commons.ExecutionContext.Implicits.global
val logic = for {
httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString)
tagsParam = httpParams.filter(_.name == "tags").map(_.values).headOption
functionsParam = httpParams.filter(_.name == "functions").map(_.values).headOption
localeParam = httpParams.filter(param => param.name == "locale" || param.name == "language").map(_.values).flatten.headOption
contentParam = httpParams.filter(_.name == "content").map(_.values).flatten.flatMap(ResourceDocsAPIMethodsUtil.stringToContentParam).headOption
apiCollectionIdParam = httpParams.filter(_.name == "api-collection-id").map(_.values).flatten.headOption
tags = tagsParam.map(_.map(ResourceDocTag(_)))
functions = functionsParam.map(_.toList)
requestedApiVersion <- Future(ApiVersionUtils.valueOf(requestedApiVersionString))
resourceDocs = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil)
filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions)
resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam)
} yield convertAnyToJsonString(resourceDocsJson)
Ok(IO.fromFuture(IO(logic)))
val response = for {
cc <- Http4sCallContextBuilder.fromRequest(req, implementedInApiVersion.toString)
result <- IO.fromFuture(IO {
// Check resource_docs_requires_role property
val resourceDocsRequireRole = getPropsAsBoolValue("resource_docs_requires_role", false)
for {
// Authentication based on property
(boxUser, cc1) <- if (resourceDocsRequireRole)
authenticatedAccess(cc)
else
anonymousAccess(cc)
// Role check based on property
_ <- if (resourceDocsRequireRole) {
NewStyle.function.hasAtLeastOneEntitlement(
failMsg = UserHasMissingRoles + canReadResourceDoc.toString
)("", boxUser.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc1)
} else {
Future.successful(())
}
httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString)
tagsParam = httpParams.filter(_.name == "tags").map(_.values).headOption
functionsParam = httpParams.filter(_.name == "functions").map(_.values).headOption
localeParam = httpParams.filter(param => param.name == "locale" || param.name == "language").map(_.values).flatten.headOption
contentParam = httpParams.filter(_.name == "content").map(_.values).flatten.flatMap(ResourceDocsAPIMethodsUtil.stringToContentParam).headOption
apiCollectionIdParam = httpParams.filter(_.name == "api-collection-id").map(_.values).flatten.headOption
tags = tagsParam.map(_.map(ResourceDocTag(_)))
functions = functionsParam.map(_.toList)
requestedApiVersion <- Future(ApiVersionUtils.valueOf(requestedApiVersionString))
resourceDocs = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil)
filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions)
resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam)
} yield convertAnyToJsonString(resourceDocsJson)
})
} yield result
Ok(response).map(_.withContentType(jsonContentType))
}
// Example endpoint demonstrating full validation chain with ResourceDocMiddleware
// This endpoint requires: authentication + bank validation + account validation + view validation
// When using ResourceDocMiddleware, these validations are automatic based on path parameters
resourceDocs += ResourceDoc(
null,
implementedInApiVersion,
nameOf(getAccountByIdWithMiddleware),
"GET",
"/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account",
"Get Account by Id (http4s with middleware)",
s"""Get account by id with automatic validation via ResourceDocMiddleware.
|
|This endpoint demonstrates the full validation chain:
|* Authentication (required)
|* Bank existence validation (BANK_ID in path)
|* Account existence validation (ACCOUNT_ID in path)
|* View access validation (VIEW_ID in path)
|
|${userAuthenticationMessage(true)}""",
EmptyBody,
moderatedAccountJSON,
List(UserNotLoggedIn, BankNotFound, BankAccountNotFound, ViewNotFound, UserNoPermissionAccessView, UnknownError),
apiTagAccount :: Nil,
http4sPartialFunction = Some(getAccountByIdWithMiddleware)
)
// Route: GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account
// When used with ResourceDocMiddleware, validation is automatic
val getAccountByIdWithMiddleware: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "banks" / bankId / "accounts" / accountId / viewId / "account" =>
import com.openbankproject.commons.ExecutionContext.Implicits.global
// When using middleware, validated objects are available in request attributes
val userOpt = Http4sVaultKeys.getUser(req)
val bankOpt = Http4sVaultKeys.getBank(req)
val accountOpt = Http4sVaultKeys.getBankAccount(req)
val viewOpt = Http4sVaultKeys.getView(req)
val ccOpt = Http4sVaultKeys.getCallContext(req)
val response = for {
// If middleware was used, objects are already validated and available
// If not using middleware, we need to build CallContext and validate manually
cc <- ccOpt match {
case Some(existingCC) => IO.pure(existingCC)
case None => Http4sCallContextBuilder.fromRequest(req, implementedInApiVersion.toString)
}
result <- IO.fromFuture(IO {
for {
// If middleware was used, these are already validated
// If not, we need to validate manually
(boxUser, cc1) <- if (userOpt.isDefined) {
Future.successful((net.liftweb.common.Full(userOpt.get), Some(cc)))
} else {
authenticatedAccess(cc)
}
(bank, cc2) <- if (bankOpt.isDefined) {
Future.successful((bankOpt.get, cc1))
} else {
NewStyle.function.getBank(com.openbankproject.commons.model.BankId(bankId), cc1)
}
(account, cc3) <- if (accountOpt.isDefined) {
Future.successful((accountOpt.get, cc2))
} else {
NewStyle.function.getBankAccount(
com.openbankproject.commons.model.BankId(bankId),
com.openbankproject.commons.model.AccountId(accountId),
cc2
)
}
(view, cc4) <- if (viewOpt.isDefined) {
Future.successful((viewOpt.get, cc3))
} else {
code.api.util.newstyle.ViewNewStyle.checkViewAccessAndReturnView(
com.openbankproject.commons.model.ViewId(viewId),
com.openbankproject.commons.model.BankIdAccountId(
com.openbankproject.commons.model.BankId(bankId),
com.openbankproject.commons.model.AccountId(accountId)
),
boxUser.toOption,
cc3
).map(v => (v, cc3))
}
// Create simple account response (avoiding complex moderated account dependencies)
accountResponse = Map(
"bank_id" -> bankId,
"account_id" -> accountId,
"view_id" -> viewId,
"label" -> account.label,
"bank_name" -> bank.fullName
)
} yield convertAnyToJsonString(accountResponse)
})
} yield result
Ok(response).map(_.withContentType(jsonContentType))
}
// All routes combined
// All routes combined (without middleware - for direct use)
val allRoutes: HttpRoutes[IO] =
Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] =>
root(req).orElse(getBanks(req)).orElse(getResourceDocsObpV700(req))
root(req)
.orElse(getBanks(req))
.orElse(getResourceDocsObpV700(req))
.orElse(getAccountByIdWithMiddleware(req))
}
// Routes wrapped with ResourceDocMiddleware for automatic validation
val allRoutesWithMiddleware: HttpRoutes[IO] =
ResourceDocMiddleware.apply(resourceDocs)(allRoutes)
}
val wrappedRoutesV700Services: HttpRoutes[IO] = CallContextMiddleware.withCallContext(Implementations7_0_0.allRoutes)
// Routes with ResourceDocMiddleware - provides automatic validation based on ResourceDoc metadata
// For endpoints that need custom validation (like resource-docs with resource_docs_requires_role),
// the validation is handled within the endpoint itself
val wrappedRoutesV700Services: HttpRoutes[IO] = Implementations7_0_0.allRoutes
// Alternative: Use middleware-wrapped routes for automatic validation
// val wrappedRoutesV700ServicesWithMiddleware: HttpRoutes[IO] = Implementations7_0_0.allRoutesWithMiddleware
}

View File

@ -0,0 +1,644 @@
package code.api.v7_0_0
import cats.effect._
import code.api.APIFailureNewStyle
import code.api.util.APIUtil.ResourceDoc
import code.api.util.ErrorMessages._
import code.api.util.{CallContext => SharedCallContext}
import com.openbankproject.commons.model.{Bank, BankAccount, BankId, AccountId, ViewId, BankIdAccountId, CounterpartyTrait, User, View}
import net.liftweb.common.{Box, Empty, Full, Failure => LiftFailure}
import net.liftweb.http.provider.HTTPParam
import net.liftweb.json.{Extraction, compactRender}
import net.liftweb.json.JsonDSL._
import org.http4s._
import org.http4s.headers.`Content-Type`
import org.typelevel.ci.CIString
import org.typelevel.vault.Key
import java.util.{Date, UUID}
import scala.collection.mutable.ArrayBuffer
import scala.language.higherKinds
/**
* Http4s support for ResourceDoc-driven validation.
*
* This file contains:
* - Http4sCallContextBuilder: Builds shared CallContext from http4s Request[IO]
* - Http4sVaultKeys: Vault keys for storing validated objects in request attributes
* - ResourceDocMatcher: Matches http4s requests to ResourceDoc entries
* - ResourceDocMiddleware: Validation chain middleware for http4s
* - ErrorResponseConverter: Converts OBP errors to http4s Response[IO]
*/
/**
* Vault keys for storing validated objects in http4s request attributes.
* These keys allow middleware to pass validated objects to endpoint handlers.
*/
object Http4sVaultKeys {
// Use shared CallContext from code.api.util.ApiSession
val callContextKey: Key[SharedCallContext] =
Key.newKey[IO, SharedCallContext].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
val userKey: Key[User] =
Key.newKey[IO, User].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
val bankKey: Key[Bank] =
Key.newKey[IO, Bank].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
val bankAccountKey: Key[BankAccount] =
Key.newKey[IO, BankAccount].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
val viewKey: Key[View] =
Key.newKey[IO, View].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
val counterpartyKey: Key[CounterpartyTrait] =
Key.newKey[IO, CounterpartyTrait].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
/**
* Helper methods for accessing validated objects from request attributes
*/
def getCallContext(req: Request[IO]): Option[SharedCallContext] =
req.attributes.lookup(callContextKey)
def getUser(req: Request[IO]): Option[User] =
req.attributes.lookup(userKey)
def getBank(req: Request[IO]): Option[Bank] =
req.attributes.lookup(bankKey)
def getBankAccount(req: Request[IO]): Option[BankAccount] =
req.attributes.lookup(bankAccountKey)
def getView(req: Request[IO]): Option[View] =
req.attributes.lookup(viewKey)
def getCounterparty(req: Request[IO]): Option[CounterpartyTrait] =
req.attributes.lookup(counterpartyKey)
}
/**
* Builds shared CallContext from http4s Request[IO].
*
* This builder extracts all necessary request data and populates the shared CallContext,
* enabling the existing authentication and validation code to work with http4s requests.
*/
object Http4sCallContextBuilder {
/**
* Build CallContext from http4s Request[IO]
* Populates all fields needed by getUserAndSessionContextFuture
*
* @param request The http4s request
* @param apiVersion The API version string (e.g., "v7.0.0")
* @return IO[SharedCallContext] with all request data populated
*/
def fromRequest(request: Request[IO], apiVersion: String): IO[SharedCallContext] = {
for {
body <- request.bodyText.compile.string.map(s => if (s.isEmpty) None else Some(s))
} yield SharedCallContext(
url = request.uri.renderString,
verb = request.method.name,
implementedInVersion = apiVersion,
correlationId = extractCorrelationId(request),
ipAddress = extractIpAddress(request),
requestHeaders = extractHeaders(request),
httpBody = body,
authReqHeaderField = extractAuthHeader(request),
directLoginParams = extractDirectLoginParams(request),
oAuthParams = extractOAuthParams(request),
startTime = Some(new Date())
)
}
/**
* Extract headers from http4s request and convert to List[HTTPParam]
*/
private def extractHeaders(request: Request[IO]): List[HTTPParam] = {
request.headers.headers.map { h =>
HTTPParam(h.name.toString, List(h.value))
}.toList
}
/**
* Extract correlation ID from X-Request-ID header or generate a new UUID
*/
private def extractCorrelationId(request: Request[IO]): String = {
request.headers.get(CIString("X-Request-ID"))
.map(_.head.value)
.getOrElse(UUID.randomUUID().toString)
}
/**
* Extract IP address from X-Forwarded-For header or request remote address
*/
private def extractIpAddress(request: Request[IO]): String = {
request.headers.get(CIString("X-Forwarded-For"))
.map(_.head.value.split(",").head.trim)
.orElse(request.remoteAddr.map(_.toUriString))
.getOrElse("")
}
/**
* Extract Authorization header value as Box[String]
*/
private def extractAuthHeader(request: Request[IO]): Box[String] = {
request.headers.get(CIString("Authorization"))
.map(h => Full(h.head.value))
.getOrElse(Empty)
}
/**
* Extract DirectLogin header parameters if present
* DirectLogin header format: DirectLogin token="xxx"
*/
private def extractDirectLoginParams(request: Request[IO]): Map[String, String] = {
request.headers.get(CIString("DirectLogin"))
.map(h => parseDirectLoginHeader(h.head.value))
.getOrElse(Map.empty)
}
/**
* Parse DirectLogin header value into parameter map
* Format: DirectLogin token="xxx", username="yyy"
*/
private def parseDirectLoginHeader(headerValue: String): Map[String, String] = {
val pattern = """(\w+)="([^"]*)"""".r
pattern.findAllMatchIn(headerValue).map { m =>
m.group(1) -> m.group(2)
}.toMap
}
/**
* Extract OAuth parameters from Authorization header if OAuth
*/
private def extractOAuthParams(request: Request[IO]): Map[String, String] = {
request.headers.get(CIString("Authorization"))
.filter(_.head.value.startsWith("OAuth "))
.map(h => parseOAuthHeader(h.head.value))
.getOrElse(Map.empty)
}
/**
* Parse OAuth Authorization header value into parameter map
* Format: OAuth oauth_consumer_key="xxx", oauth_token="yyy", ...
*/
private def parseOAuthHeader(headerValue: String): Map[String, String] = {
val oauthPart = headerValue.stripPrefix("OAuth ").trim
val pattern = """(\w+)="([^"]*)"""".r
pattern.findAllMatchIn(oauthPart).map { m =>
m.group(1) -> m.group(2)
}.toMap
}
}
/**
* Matches http4s requests to ResourceDoc entries.
*
* ResourceDoc entries use URL templates with uppercase variable names:
* - BANK_ID, ACCOUNT_ID, VIEW_ID, COUNTERPARTY_ID
*
* This matcher finds the corresponding ResourceDoc for a given request
* and extracts path parameters.
*/
object ResourceDocMatcher {
/**
* Find ResourceDoc matching the given verb and path
*
* @param verb HTTP verb (GET, POST, PUT, DELETE, etc.)
* @param path Request path
* @param resourceDocs Collection of ResourceDoc entries to search
* @return Option[ResourceDoc] if a match is found
*/
def findResourceDoc(
verb: String,
path: Uri.Path,
resourceDocs: ArrayBuffer[ResourceDoc]
): Option[ResourceDoc] = {
val pathString = path.renderString
resourceDocs.find { doc =>
doc.requestVerb.equalsIgnoreCase(verb) && matchesUrlTemplate(pathString, doc.requestUrl)
}
}
/**
* Check if a path matches a URL template
* Template segments in uppercase are treated as variables
*/
private def matchesUrlTemplate(path: String, template: String): Boolean = {
val pathSegments = path.split("/").filter(_.nonEmpty)
val templateSegments = template.split("/").filter(_.nonEmpty)
if (pathSegments.length != templateSegments.length) {
false
} else {
pathSegments.zip(templateSegments).forall { case (pathSeg, templateSeg) =>
// Uppercase segments are variables (BANK_ID, ACCOUNT_ID, etc.)
isTemplateVariable(templateSeg) || pathSeg == templateSeg
}
}
}
/**
* Check if a template segment is a variable (uppercase)
*/
private def isTemplateVariable(segment: String): Boolean = {
segment.nonEmpty && segment.forall(c => c.isUpper || c == '_' || c.isDigit)
}
/**
* Extract path parameters from matched ResourceDoc
*
* @param path Request path
* @param resourceDoc Matched ResourceDoc
* @return Map with keys: BANK_ID, ACCOUNT_ID, VIEW_ID, COUNTERPARTY_ID (if present)
*/
def extractPathParams(
path: Uri.Path,
resourceDoc: ResourceDoc
): Map[String, String] = {
val pathString = path.renderString
val pathSegments = pathString.split("/").filter(_.nonEmpty)
val templateSegments = resourceDoc.requestUrl.split("/").filter(_.nonEmpty)
if (pathSegments.length != templateSegments.length) {
Map.empty
} else {
pathSegments.zip(templateSegments).collect {
case (pathSeg, templateSeg) if isTemplateVariable(templateSeg) =>
templateSeg -> pathSeg
}.toMap
}
}
/**
* Update CallContext with matched ResourceDoc
* MUST be called after successful match for metrics/rate limiting consistency
*
* @param callContext Current CallContext
* @param resourceDoc Matched ResourceDoc
* @return Updated CallContext with resourceDocument and operationId set
*/
def attachToCallContext(
callContext: SharedCallContext,
resourceDoc: ResourceDoc
): SharedCallContext = {
callContext.copy(
resourceDocument = Some(resourceDoc),
operationId = Some(resourceDoc.operationId)
)
}
}
/**
* Validated context containing all validated objects from the middleware chain.
* This is passed to endpoint handlers after successful validation.
*/
case class ValidatedContext(
user: Option[User],
bank: Option[Bank],
bankAccount: Option[BankAccount],
view: Option[View],
counterparty: Option[CounterpartyTrait],
callContext: SharedCallContext
)
/**
* Converts OBP errors to http4s Response[IO].
* Uses Lift JSON for serialization (consistent with OBP codebase).
*/
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
*/
private def toJsonString(error: OBPErrorResponse): String = {
val json = ("code" -> error.code) ~ ("message" -> error.message)
compactRender(json)
}
/**
* Convert an error to http4s Response[IO]
*/
def toHttp4sResponse(error: Throwable, callContext: SharedCallContext): IO[Response[IO]] = {
error match {
case e: APIFailureNewStyle =>
apiFailureToResponse(e, callContext)
case e =>
unknownErrorToResponse(e, callContext)
}
}
/**
* Convert APIFailureNewStyle to http4s Response
*/
def apiFailureToResponse(failure: APIFailureNewStyle, callContext: SharedCallContext): 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 Box Failure to http4s Response
*/
def boxFailureToResponse(failure: LiftFailure, callContext: SharedCallContext): 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
*/
def unknownErrorToResponse(e: Throwable, callContext: SharedCallContext): 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: SharedCallContext): 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))
)
}
}
/**
* ResourceDoc-driven validation middleware for http4s.
*
* This middleware wraps http4s routes with automatic validation based on ResourceDoc metadata:
* - Authentication (if required by ResourceDoc)
* - Bank existence validation (if BANK_ID in path)
* - Role-based authorization (if roles specified in ResourceDoc)
* - Account existence validation (if ACCOUNT_ID in path)
* - View access validation (if VIEW_ID in path)
* - Counterparty existence validation (if COUNTERPARTY_ID in path)
*
* Validation order matches Lift: auth bank roles account view counterparty
*/
object ResourceDocMiddleware {
import cats.data.{Kleisli, OptionT}
import code.api.util.APIUtil
import code.api.util.NewStyle
import code.api.util.newstyle.ViewNewStyle
type HttpF[A] = OptionT[IO, A]
type Middleware[F[_]] = HttpRoutes[F] => HttpRoutes[F]
/**
* Check if ResourceDoc requires authentication based on errorResponseBodies
*/
private def needsAuthentication(resourceDoc: ResourceDoc): Boolean = {
resourceDoc.errorResponseBodies.contains($UserNotLoggedIn)
}
/**
* Create middleware that applies ResourceDoc-driven validation
*
* @param resourceDocs Collection of ResourceDoc entries for matching
* @return Middleware that wraps routes with validation
*/
def apply(resourceDocs: ArrayBuffer[ResourceDoc]): Middleware[IO] = { routes =>
Kleisli[HttpF, Request[IO], Response[IO]] { req =>
OptionT.liftF(validateAndRoute(req, routes, resourceDocs))
}
}
/**
* Validate request and route to handler if validation passes
*/
private def validateAndRoute(
req: Request[IO],
routes: HttpRoutes[IO],
resourceDocs: ArrayBuffer[ResourceDoc]
): IO[Response[IO]] = {
for {
// Build CallContext from request
cc <- Http4sCallContextBuilder.fromRequest(req, "v7.0.0")
// Match ResourceDoc
resourceDocOpt = ResourceDocMatcher.findResourceDoc(req.method.name, req.uri.path, resourceDocs)
response <- resourceDocOpt match {
case Some(resourceDoc) =>
// Attach ResourceDoc to CallContext for metrics/rate limiting
val ccWithDoc = ResourceDocMatcher.attachToCallContext(cc, resourceDoc)
val pathParams = ResourceDocMatcher.extractPathParams(req.uri.path, resourceDoc)
// Run validation chain
runValidationChain(req, resourceDoc, ccWithDoc, pathParams, routes)
case None =>
// No matching ResourceDoc - pass through to routes
routes.run(req).getOrElseF(
IO.pure(Response[IO](org.http4s.Status.NotFound))
)
}
} yield response
}
/**
* Run the validation chain in order: auth bank roles account view counterparty
*/
private def runValidationChain(
req: Request[IO],
resourceDoc: ResourceDoc,
cc: SharedCallContext,
pathParams: Map[String, String],
routes: HttpRoutes[IO]
): IO[Response[IO]] = {
import com.openbankproject.commons.ExecutionContext.Implicits.global
// Step 1: Authentication
val authResult: IO[Either[Response[IO], (Box[User], SharedCallContext)]] =
if (needsAuthentication(resourceDoc)) {
IO.fromFuture(IO(APIUtil.authenticatedAccess(cc))).attempt.map {
case Right((boxUser, Some(updatedCC))) =>
boxUser match {
case Full(_) => Right((boxUser, updatedCC))
case Empty => Left(Response[IO](org.http4s.Status.Unauthorized))
case LiftFailure(_, _, _) => Left(Response[IO](org.http4s.Status.Unauthorized))
}
case Right((boxUser, None)) => Right((boxUser, cc))
case Left(e: APIFailureNewStyle) =>
Left(Response[IO](org.http4s.Status.fromInt(e.failCode).getOrElse(org.http4s.Status.Unauthorized)))
case Left(_) => Left(Response[IO](org.http4s.Status.Unauthorized))
}
} else {
IO.fromFuture(IO(APIUtil.anonymousAccess(cc))).attempt.map {
case Right((boxUser, Some(updatedCC))) => Right((boxUser, updatedCC))
case Right((boxUser, None)) => Right((boxUser, cc))
case Left(_) => Right((Empty, cc))
}
}
authResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right((boxUser, cc1)) =>
// Step 2: Bank validation (if BANK_ID in path)
val bankResult: IO[Either[Response[IO], (Option[Bank], SharedCallContext)]] =
pathParams.get("BANK_ID") match {
case Some(bankIdStr) =>
IO.fromFuture(IO(NewStyle.function.getBank(BankId(bankIdStr), Some(cc1)))).attempt.map {
case Right((bank, Some(updatedCC))) => Right((Some(bank), updatedCC))
case Right((bank, None)) => Right((Some(bank), cc1))
case Left(_: APIFailureNewStyle) =>
Left(Response[IO](org.http4s.Status.NotFound))
case Left(_) => Left(Response[IO](org.http4s.Status.NotFound))
}
case None => IO.pure(Right((None, cc1)))
}
bankResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right((bankOpt, cc2)) =>
// Step 3: Role authorization (if roles specified)
val rolesResult: IO[Either[Response[IO], SharedCallContext]] =
resourceDoc.roles match {
case Some(roles) if roles.nonEmpty && boxUser.isDefined =>
val userId = boxUser.map(_.userId).getOrElse("")
val bankId = bankOpt.map(_.bankId.value).getOrElse("")
// Check if user has at least one of the required roles
val hasRole = roles.exists { role =>
val checkBankId = if (role.requiresBankId) bankId else ""
APIUtil.hasEntitlement(checkBankId, userId, role)
}
if (hasRole) {
IO.pure(Right(cc2))
} else {
IO.pure(Left(Response[IO](org.http4s.Status.Forbidden)))
}
case _ => IO.pure(Right(cc2))
}
rolesResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right(cc3) =>
// Step 4: Account validation (if ACCOUNT_ID in path)
val accountResult: IO[Either[Response[IO], (Option[BankAccount], SharedCallContext)]] =
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID")) match {
case (Some(bankIdStr), Some(accountIdStr)) =>
IO.fromFuture(IO(
NewStyle.function.getBankAccount(BankId(bankIdStr), AccountId(accountIdStr), Some(cc3))
)).attempt.map {
case Right((account, Some(updatedCC))) => Right((Some(account), updatedCC))
case Right((account, None)) => Right((Some(account), cc3))
case Left(_) => Left(Response[IO](org.http4s.Status.NotFound))
}
case _ => IO.pure(Right((None, cc3)))
}
accountResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right((accountOpt, cc4)) =>
// Step 5: View validation (if VIEW_ID in path)
val viewResult: IO[Either[Response[IO], (Option[View], SharedCallContext)]] =
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("VIEW_ID")) match {
case (Some(bankIdStr), Some(accountIdStr), Some(viewIdStr)) =>
val bankIdAccountId = BankIdAccountId(BankId(bankIdStr), AccountId(accountIdStr))
IO.fromFuture(IO(
ViewNewStyle.checkViewAccessAndReturnView(
ViewId(viewIdStr),
bankIdAccountId,
boxUser.toOption,
Some(cc4)
)
)).attempt.map {
case Right(view) => Right((Some(view), cc4))
case Left(_) => Left(Response[IO](org.http4s.Status.Forbidden))
}
case _ => IO.pure(Right((None, cc4)))
}
viewResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right((viewOpt, cc5)) =>
// Step 6: Counterparty validation (if COUNTERPARTY_ID in path)
val counterpartyResult: IO[Either[Response[IO], (Option[CounterpartyTrait], SharedCallContext)]] =
pathParams.get("COUNTERPARTY_ID") match {
case Some(_) =>
// For now, skip counterparty validation - can be added later
IO.pure(Right((None, cc5)))
case None => IO.pure(Right((None, cc5)))
}
counterpartyResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right((counterpartyOpt, finalCC)) =>
// All validations passed - store validated context and invoke route
val validatedContext = ValidatedContext(
user = boxUser.toOption,
bank = bankOpt,
bankAccount = accountOpt,
view = viewOpt,
counterparty = counterpartyOpt,
callContext = finalCC
)
// Store validated objects in request attributes
var updatedReq = req.withAttribute(Http4sVaultKeys.callContextKey, finalCC)
boxUser.toOption.foreach { user =>
updatedReq = updatedReq.withAttribute(Http4sVaultKeys.userKey, user)
}
bankOpt.foreach { bank =>
updatedReq = updatedReq.withAttribute(Http4sVaultKeys.bankKey, bank)
}
accountOpt.foreach { account =>
updatedReq = updatedReq.withAttribute(Http4sVaultKeys.bankAccountKey, account)
}
viewOpt.foreach { view =>
updatedReq = updatedReq.withAttribute(Http4sVaultKeys.viewKey, view)
}
counterpartyOpt.foreach { counterparty =>
updatedReq = updatedReq.withAttribute(Http4sVaultKeys.counterpartyKey, counterparty)
}
// Invoke the original route
routes.run(updatedReq).getOrElseF(
IO.pure(Response[IO](org.http4s.Status.NotFound))
)
}
}
}
}
}
}
}
}