Merge pull request #2659 from constantine2nd/develop

Merge conflicts resolved, HTTP4S
This commit is contained in:
Simon Redfern 2026-01-27 16:25:50 +01:00 committed by GitHub
commit 61c83581ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 226 additions and 241 deletions

View File

@ -33,4 +33,4 @@ jobs:
workflow_id: 'build_container_develop_branch.yml',
ref: 'refs/heads/develop'
});
if: steps.baseupdatecheck.outputs.needs-updating == 'true'
if: steps.baseupdatecheck.outputs.needs-updating == 'true'

View File

@ -153,4 +153,4 @@ jobs:
cosign sign -y --key cosign.key \
docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC
env:
COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}"
COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}"

View File

@ -149,4 +149,4 @@ jobs:
cosign sign -y --key cosign.key \
docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA
env:
COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}"
COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}"

View File

@ -118,4 +118,4 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: ${{ github.sha }}
path: pull/
path: pull/

View File

@ -50,4 +50,4 @@ jobs:
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: "trivy-results.sarif"
sarif_file: "trivy-results.sarif"

View File

@ -1,27 +1,26 @@
package code.api.util.http4s
import cats.data.{Kleisli, OptionT}
import cats.data.{EitherT, Kleisli, OptionT}
import cats.effect._
import code.api.APIFailureNewStyle
import code.api.util.APIUtil.ResourceDoc
import code.api.util.ErrorMessages._
import code.api.util.{APIUtil, CallContext, NewStyle}
import code.api.util.newstyle.ViewNewStyle
import code.api.util.{APIUtil, CallContext, NewStyle}
import code.util.Helper.MdcLoggable
import com.openbankproject.commons.model._
import net.liftweb.common.{Box, Empty, Full, Failure => LiftFailure}
import net.liftweb.common.{Box, Empty, Full}
import org.http4s._
import org.http4s.headers.`Content-Type`
import scala.collection.mutable.ArrayBuffer
import scala.language.higherKinds
/**
* ResourceDoc-driven validation middleware for http4s.
*
*
* This middleware wraps http4s routes with automatic validation based on ResourceDoc metadata.
* Validation is performed in a specific order to ensure security and proper error responses.
*
*
* VALIDATION ORDER:
* 1. Authentication - Check if user is authenticated (if required by ResourceDoc)
* 2. Authorization - Verify user has required roles/entitlements
@ -29,18 +28,42 @@ import scala.language.higherKinds
* 4. Account validation - Validate ACCOUNT_ID path parameter (if present)
* 5. View validation - Validate VIEW_ID and check user access (if present)
* 6. Counterparty validation - Validate COUNTERPARTY_ID (if present)
*
*
* Validated entities are stored in CallContext fields for use in endpoint handlers.
*/
object ResourceDocMiddleware extends MdcLoggable{
object ResourceDocMiddleware extends MdcLoggable {
/** Type alias for http4s OptionT route effect */
type HttpF[A] = OptionT[IO, A]
type Middleware[F[_]] = HttpRoutes[F] => HttpRoutes[F]
/** Type alias for validation effect using EitherT */
type Validation[A] = EitherT[IO, Response[IO], A]
/** JSON content type for responses */
private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json)
/**
* Context that accumulates all validated entities during request processing.
* This context is passed along the validation chain.
*/
final case class ValidationContext(
user: Box[User] = Empty,
callContext: CallContext,
bank: Option[Bank] = None,
account: Option[BankAccount] = None,
view: Option[View] = None,
counterparty: Option[CounterpartyTrait] = None
)
/** Simple DSL for success/failure in the validation chain */
object DSL {
def success[A](a: A): Validation[A] = EitherT.rightT(a)
def failure(resp: Response[IO]): Validation[Nothing] = EitherT.leftT(resp)
}
/**
* Check if ResourceDoc requires authentication.
*
*
* Authentication is required if:
* - ResourceDoc errorResponseBodies contains $AuthenticatedUserIsRequired
* - ResourceDoc has roles (roles always require authenticated user)
@ -53,241 +76,203 @@ object ResourceDocMiddleware extends MdcLoggable{
resourceDoc.errorResponseBodies.contains($AuthenticatedUserIsRequired) || resourceDoc.roles.exists(_.nonEmpty)
}
}
/**
* Create middleware that applies ResourceDoc-driven validation.
*
* @param resourceDocs Collection of ResourceDoc entries for matching
* @return Middleware that wraps HttpRoutes with validation
* Middleware factory: wraps HttpRoutes with ResourceDoc validation.
* Finds the matching ResourceDoc, validates the request, and enriches CallContext.
*/
def apply(resourceDocs: ArrayBuffer[ResourceDoc]): Middleware[IO] = { routes =>
Kleisli[HttpF, Request[IO], Response[IO]] { req =>
OptionT(validateAndRoute(req, routes, resourceDocs).map(Option(_)))
}
}
/**
* Validate request and route to handler if validation passes.
*
* Steps:
* 1. Build CallContext from request
* 2. Find matching ResourceDoc
* 3. Run validation chain
* 4. Route to handler with enriched CallContext
*/
private def validateAndRoute(
req: Request[IO],
routes: HttpRoutes[IO],
resourceDocs: ArrayBuffer[ResourceDoc]
): IO[Response[IO]] = {
for {
cc <- Http4sCallContextBuilder.fromRequest(req, "v7.0.0")
resourceDocOpt = ResourceDocMatcher.findResourceDoc(req.method.name, req.uri.path, resourceDocs)
response <- resourceDocOpt match {
case Some(resourceDoc) =>
val ccWithDoc = ResourceDocMatcher.attachToCallContext(cc, resourceDoc)
val pathParams = ResourceDocMatcher.extractPathParams(req.uri.path, resourceDoc)
runValidationChainForRoutes(req, resourceDoc, ccWithDoc, pathParams, routes)
.map(ensureJsonContentType)
case None =>
routes.run(req).getOrElseF(IO.pure(Response[IO](org.http4s.Status.NotFound)))
def apply(resourceDocs: ArrayBuffer[ResourceDoc]): HttpRoutes[IO] => HttpRoutes[IO] = { routes =>
Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] =>
// Build initial CallContext from request
OptionT.liftF(Http4sCallContextBuilder.fromRequest(req, "v7.0.0")).flatMap { cc =>
ResourceDocMatcher.findResourceDoc(req.method.name, req.uri.path, resourceDocs) match {
case Some(resourceDoc) =>
val ccWithDoc = ResourceDocMatcher.attachToCallContext(cc, resourceDoc)
val pathParams = ResourceDocMatcher.extractPathParams(req.uri.path, resourceDoc)
// Run full validation chain
OptionT(validateRequest(req, resourceDoc, pathParams, ccWithDoc, routes).map(Option(_)))
case None =>
// No matching ResourceDoc: fallback to original route
routes.run(req)
}
}
} yield response
}
}
/**
* Ensure response has JSON content type.
* Executes the full validation chain for the request.
* Returns either an error Response or enriched request routed to the handler.
*/
private def validateRequest(
req: Request[IO],
resourceDoc: ResourceDoc,
pathParams: Map[String, String],
cc: CallContext,
routes: HttpRoutes[IO]
): IO[Response[IO]] = {
// Initial context with just CallContext
val initialContext = ValidationContext(callContext = cc)
// Compose all validation steps using EitherT
val result: Validation[ValidationContext] = for {
context <- authenticate(req, resourceDoc, initialContext)
context <- authorizeRoles(resourceDoc, pathParams, context)
context <- validateBank(pathParams, context)
context <- validateAccount(pathParams, context)
context <- validateView(pathParams, context)
context <- validateCounterparty(pathParams, context)
} yield context
// Convert Validation result to Response
result.value.flatMap {
case Left(errorResponse) => IO.pure(ensureJsonContentType(errorResponse)) // Ensure all error responses are JSON
case Right(validCtx) =>
// Enrich request with validated CallContext
val enrichedReq = req.withAttribute(
Http4sRequestAttributes.callContextKey,
validCtx.callContext.copy(
bank = validCtx.bank,
bankAccount = validCtx.account,
view = validCtx.view,
counterparty = validCtx.counterparty
)
)
routes.run(enrichedReq)
.map(ensureJsonContentType) // Ensure routed response has JSON content type
.getOrElseF(IO.pure(ensureJsonContentType(Response[IO](org.http4s.Status.NotFound))))
}
}
/** Authentication step: verifies user and updates ValidationContext */
private def authenticate(req: Request[IO], resourceDoc: ResourceDoc, ctx: ValidationContext): Validation[ValidationContext] = {
val needsAuth = ResourceDocMiddleware.needsAuthentication(resourceDoc)
logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth")
val io =
if (needsAuth) IO.fromFuture(IO(APIUtil.authenticatedAccess(ctx.callContext)))
else IO.fromFuture(IO(APIUtil.anonymousAccess(ctx.callContext)))
EitherT(
io.attempt.flatMap {
case Right((boxUser, Some(updatedCC))) =>
IO.pure(Right(ctx.copy(user = boxUser, callContext = updatedCC)))
case Right((boxUser, None)) =>
IO.pure(Right(ctx.copy(user = boxUser)))
case Left(e: APIFailureNewStyle) =>
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
case Left(_) =>
ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, ctx.callContext).map(Left(_))
}
)
}
/** Role authorization step: ensures user has required roles */
private def authorizeRoles(resourceDoc: ResourceDoc, pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
import DSL._
resourceDoc.roles match {
case Some(roles) if roles.nonEmpty =>
ctx.user match {
case Full(user) =>
val bankId = pathParams.getOrElse("BANK_ID", "")
val ok = roles.exists { role =>
val checkBankId = if (role.requiresBankId) bankId else ""
APIUtil.hasEntitlement(checkBankId, user.userId, role)
}
if (ok) success(ctx)
else EitherT[IO, Response[IO], ValidationContext](
ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(", "), ctx.callContext)
.map[Either[Response[IO], ValidationContext]](Left(_))
)
case _ =>
EitherT[IO, Response[IO], ValidationContext](
ErrorResponseConverter
.createErrorResponse(401, $AuthenticatedUserIsRequired, ctx.callContext)
.map[Either[Response[IO], ValidationContext]](resp => Left(resp))
)
}
case _ => success(ctx)
}
}
/** Bank validation: checks BANK_ID and fetches bank */
private def validateBank(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
pathParams.get("BANK_ID") match {
case Some(bankId) =>
EitherT(
IO.fromFuture(IO(NewStyle.function.getBank(BankId(bankId), Some(ctx.callContext))))
.attempt.flatMap {
case Right((bank, Some(updatedCC))) => IO.pure(Right(ctx.copy(bank = Some(bank), callContext = updatedCC)))
case Right((bank, None)) => IO.pure(Right(ctx.copy(bank = Some(bank))))
case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
case Left(_) => ErrorResponseConverter.createErrorResponse(404, BankNotFound + s": $bankId", ctx.callContext).map(Left(_))
}
)
case None => DSL.success(ctx)
}
}
/** Account validation: checks ACCOUNT_ID and fetches bank account */
private def validateAccount(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID")) match {
case (Some(bankId), Some(accountId)) =>
EitherT(
IO.fromFuture(IO(NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(ctx.callContext))))
.attempt.flatMap {
case Right((acc, Some(updatedCC))) => IO.pure(Right(ctx.copy(account = Some(acc), callContext = updatedCC)))
case Right((acc, None)) => IO.pure(Right(ctx.copy(account = Some(acc))))
case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
case Left(_) => ErrorResponseConverter.createErrorResponse(404, BankAccountNotFound + s": bankId=$bankId, accountId=$accountId", ctx.callContext).map(Left(_))
}
)
case _ => DSL.success(ctx)
}
}
/** View validation: checks VIEW_ID and user access */
private def validateView(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("VIEW_ID")) match {
case (Some(bankId), Some(accountId), Some(viewId)) =>
EitherT(
IO.fromFuture(IO(ViewNewStyle.checkViewAccessAndReturnView(ViewId(viewId), BankIdAccountId(BankId(bankId), AccountId(accountId)), ctx.user.toOption, Some(ctx.callContext))))
.attempt.flatMap {
case Right(view) => IO.pure(Right(ctx.copy(view = Some(view))))
case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
case Left(_) => ErrorResponseConverter.createErrorResponse(403, UserNoPermissionAccessView + s": viewId=$viewId", ctx.callContext).map(Left(_))
}
)
case _ => DSL.success(ctx)
}
}
/** Counterparty validation: checks COUNTERPARTY_ID and fetches counterparty */
private def validateCounterparty(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("COUNTERPARTY_ID")) match {
case (Some(bankId), Some(accountId), Some(counterpartyId)) =>
EitherT(
IO.fromFuture(IO(NewStyle.function.getCounterpartyTrait(BankId(bankId), AccountId(accountId), counterpartyId, Some(ctx.callContext))))
.attempt.flatMap {
case Right((cp, Some(updatedCC))) => IO.pure(Right(ctx.copy(counterparty = Some(cp), callContext = updatedCC)))
case Right((cp, None)) => IO.pure(Right(ctx.copy(counterparty = Some(cp))))
case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
case Left(_) => ErrorResponseConverter.createErrorResponse(404, CounterpartyNotFound + s": counterpartyId=$counterpartyId", ctx.callContext).map(Left(_))
}
)
case _ => DSL.success(ctx)
}
}
/** Ensure the response has JSON content type */
private def ensureJsonContentType(response: Response[IO]): Response[IO] = {
response.contentType match {
case Some(contentType) if contentType.mediaType == MediaType.application.json => response
case _ => response.withContentType(jsonContentType)
}
}
/**
* Run validation chain for HttpRoutes and return Response.
*
* This method performs all validation steps in order:
* 1. Authentication (if required)
* 2. Role authorization (if roles specified)
* 3. Bank validation (if BANK_ID in path)
* 4. Account validation (if ACCOUNT_ID in path)
* 5. View validation (if VIEW_ID in path)
* 6. Counterparty validation (if COUNTERPARTY_ID in path)
*
* On success: Enriches CallContext with validated entities and routes to handler
* On failure: Returns error response immediately
*/
private def runValidationChainForRoutes(
req: Request[IO],
resourceDoc: ResourceDoc,
cc: CallContext,
pathParams: Map[String, String],
routes: HttpRoutes[IO]
): IO[Response[IO]] = {
val needsAuth = needsAuthentication(resourceDoc)
logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth")
// Step 1: Authentication
val authResult: IO[Either[Response[IO], (Box[User], CallContext)]] =
if (needsAuth) {
IO.fromFuture(IO(APIUtil.authenticatedAccess(cc))).attempt.flatMap {
case Right((boxUser, optCC)) =>
val updatedCC = optCC.getOrElse(cc)
boxUser match {
case Full(user) =>
IO.pure(Right((boxUser, updatedCC)))
case Empty =>
ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, updatedCC).map(Left(_))
case LiftFailure(msg, _, _) =>
ErrorResponseConverter.createErrorResponse(401, msg, updatedCC).map(Left(_))
}
case Left(e: APIFailureNewStyle) =>
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc).map(Left(_))
case Left(e) =>
val (code, msg) = try {
import net.liftweb.json._
implicit val formats = net.liftweb.json.DefaultFormats
val json = parse(e.getMessage)
val failCode = (json \ "failCode").extractOpt[Int].getOrElse(401)
val failMsg = (json \ "failMsg").extractOpt[String].getOrElse($AuthenticatedUserIsRequired)
(failCode, failMsg)
} catch {
case _: Exception => (401, $AuthenticatedUserIsRequired)
}
ErrorResponseConverter.createErrorResponse(code, msg, cc).map(Left(_))
}
} else {
IO.fromFuture(IO(APIUtil.anonymousAccess(cc))).attempt.flatMap {
case Right((boxUser, Some(updatedCC))) =>
IO.pure(Right((boxUser, updatedCC)))
case Right((boxUser, None)) =>
IO.pure(Right((boxUser, cc)))
case Left(e) =>
// For anonymous endpoints, continue with Empty user even if auth fails
IO.pure(Right((Empty, cc)))
}
}
authResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right((boxUser, cc1)) =>
// Step 2: Role authorization
val rolesResult: IO[Either[Response[IO], CallContext]] =
resourceDoc.roles match {
case Some(roles) if roles.nonEmpty =>
boxUser match {
case Full(user) =>
val userId = user.userId
val bankId = pathParams.get("BANK_ID").getOrElse("")
val hasRole = roles.exists { role =>
val checkBankId = if (role.requiresBankId) bankId else ""
APIUtil.hasEntitlement(checkBankId, userId, role)
}
if (hasRole) IO.pure(Right(cc1))
else ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(", "), cc1).map(Left(_))
case _ =>
ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, cc1).map(Left(_))
}
case _ => IO.pure(Right(cc1))
}
rolesResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right(cc2) =>
// Step 3: Bank validation
val bankResult: IO[Either[Response[IO], (Option[Bank], CallContext)]] =
pathParams.get("BANK_ID") match {
case Some(bankIdStr) =>
IO.fromFuture(IO(NewStyle.function.getBank(BankId(bankIdStr), Some(cc2)))).attempt.flatMap {
case Right((bank, Some(updatedCC))) =>
IO.pure(Right((Some(bank), updatedCC)))
case Right((bank, None)) =>
IO.pure(Right((Some(bank), cc2)))
case Left(e: APIFailureNewStyle) =>
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc2).map(Left(_))
case Left(e) =>
ErrorResponseConverter.createErrorResponse(404, BankNotFound + ": " + bankIdStr, cc2).map(Left(_))
}
case None => IO.pure(Right((None, cc2)))
}
bankResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right((bankOpt, cc3)) =>
// Step 4: Account validation (if ACCOUNT_ID in path)
val accountResult: IO[Either[Response[IO], (Option[BankAccount], CallContext)]] =
(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.flatMap {
case Right((account, Some(updatedCC))) => IO.pure(Right((Some(account), updatedCC)))
case Right((account, None)) => IO.pure(Right((Some(account), cc3)))
case Left(e: APIFailureNewStyle) =>
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc3).map(Left(_))
case Left(e) =>
ErrorResponseConverter.createErrorResponse(404, BankAccountNotFound + s": bankId=$bankIdStr, accountId=$accountIdStr", cc3).map(Left(_))
}
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], CallContext)]] =
(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.flatMap {
case Right(view) => IO.pure(Right((Some(view), cc4)))
case Left(e: APIFailureNewStyle) =>
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc4).map(Left(_))
case Left(e) =>
ErrorResponseConverter.createErrorResponse(403, UserNoPermissionAccessView + s": viewId=$viewIdStr", cc4).map(Left(_))
}
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], CallContext)]] =
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("COUNTERPARTY_ID")) match {
case (Some(bankIdStr), Some(accountIdStr), Some(counterpartyIdStr)) =>
IO.fromFuture(IO(NewStyle.function.getCounterpartyTrait(BankId(bankIdStr), AccountId(accountIdStr), counterpartyIdStr, Some(cc5)))).attempt.flatMap {
case Right((counterparty, Some(updatedCC))) => IO.pure(Right((Some(counterparty), updatedCC)))
case Right((counterparty, None)) => IO.pure(Right((Some(counterparty), cc5)))
case Left(e: APIFailureNewStyle) =>
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc5).map(Left(_))
case Left(e) =>
ErrorResponseConverter.createErrorResponse(404, CounterpartyNotFound + s": counterpartyId=$counterpartyIdStr", cc5).map(Left(_))
}
case _ => IO.pure(Right((None, cc5)))
}
counterpartyResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right((counterpartyOpt, finalCC)) =>
// All validations passed - update CallContext with validated entities
val enrichedCC = finalCC.copy(
bank = bankOpt,
bankAccount = accountOpt,
view = viewOpt,
counterparty = counterpartyOpt
)
// Store enriched CallContext in request attributes
val updatedReq = req.withAttribute(Http4sRequestAttributes.callContextKey, enrichedCC)
routes.run(updatedReq).getOrElseF(IO.pure(Response[IO](org.http4s.Status.NotFound)))
}
}
}
}
}
}
}
}