mirror of
https://github.com/OpenBankProject/OBP-API.git
synced 2026-02-06 11:27:05 +00:00
refactor/(api): update CallContext logic and introduce Http4s utilities
- Refactor `getUserAndSessionContextFuture` to prioritize `CallContext` fields over `S.request` for http4s compatibility - Introduce `Http4sResourceDocSupport` with utilities for validation, middleware, and error handling - Remove redundant middleware and unused `CallContext` definition in `Http4s700` - Improve modularity and enable http4s request handling in v7.0.0 API routes
This commit is contained in:
parent
59ae64b4a0
commit
f58fb77c5d
@ -3031,18 +3031,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)
|
||||
|
||||
@ -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
|
||||
@ -19,8 +20,8 @@ import net.liftweb.json.{Extraction, Formats}
|
||||
import org.http4s._
|
||||
import org.http4s.dsl.io._
|
||||
import org.http4s.headers._
|
||||
import org.typelevel.vault.Key
|
||||
|
||||
import java.util.UUID
|
||||
import scala.collection.mutable.ArrayBuffer
|
||||
import scala.concurrent.Future
|
||||
import scala.language.{higherKinds, implicitConversions}
|
||||
@ -36,21 +37,6 @@ 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
|
||||
@ -70,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)
|
||||
@ -81,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}.")
|
||||
)
|
||||
}
|
||||
))).map(_.withContentType(jsonContentType))
|
||||
(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(
|
||||
@ -119,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))
|
||||
}
|
||||
))).map(_.withContentType(jsonContentType))
|
||||
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))).map(_.withContentType(jsonContentType))
|
||||
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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user