refactor(http4s): enhance CallContext extraction and validation chain

- Add withCallContext helper method to Http4sSupport for simplified endpoint code
- Document use of http4s Vault for type-safe request attributes storage
- Clarify that validated entities (bank, bankAccount, view, counterparty) are stored within CallContext
- Reorder validation chain in ResourceDocMiddleware to check roles before entity validation
- Add special handling for resource-docs endpoint with configurable role requirement
- Extract runValidationChain method to support both middleware and endpoint wrapping patterns
- Improve authentication error handling with better Box pattern matching
- Add comprehensive documentation and usage examples for CallContext extraction
- Enhance logging for validation chain execution and debugging
This commit is contained in:
hongwei 2026-01-22 14:36:58 +01:00
parent e8999ba54c
commit dbd046bf7c
3 changed files with 291 additions and 122 deletions

View File

@ -34,8 +34,12 @@ import scala.language.higherKinds
/**
* Request attribute keys for storing CallContext in http4s requests.
*
* Note: Uses http4s Vault (org.typelevel.vault.Key) for type-safe request attributes.
* Validated entities (bank, bankAccount, view, counterparty) are stored within CallContext itself.
*/
object Http4sRequestAttributes {
import org.typelevel.vault.Key
// CallContext contains all request data and validated entities
val callContextKey: Key[CallContext] =
Key.newKey[IO, CallContext].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
@ -46,6 +50,33 @@ object Http4sRequestAttributes {
*/
def getCallContext(req: Request[IO]): Option[CallContext] =
req.attributes.lookup(callContextKey)
/**
* Helper method to extract CallContext from http4s Request and execute business logic.
* Simplifies endpoint code by handling the common pattern of extracting CallContext.
*
* Usage example:
* {{{
* val myEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] {
* case req @ GET -> Root / "banks" =>
* withCallContext(req) { cc =>
* for {
* result <- yourBusinessLogic(cc)
* response <- Ok(result)
* } yield response
* }
* }
* }}}
*
* @param req The http4s request
* @param f Function that takes CallContext and returns IO[Response]
* @return IO[Response[IO]]
*/
def withCallContext(req: Request[IO])(f: CallContext => IO[Response[IO]]): IO[Response[IO]] = {
IO.fromOption(req.attributes.lookup(callContextKey))(
new RuntimeException("CallContext not found in request attributes")
).flatMap(f)
}
}
/**

View File

@ -23,10 +23,10 @@ import scala.language.higherKinds
*
* VALIDATION ORDER:
* 1. Authentication first
* 2. BANK_ID validation (if present in path)
* 3. ACCOUNT_ID validation (if present in path)
* 4. VIEW_ID validation (if present in path)
* 5. Role authorization (if roles specified in ResourceDoc)
* 2. Roles authorization (if roles specified in ResourceDoc)
* 3. BANK_ID validation (if present in path)
* 4. ACCOUNT_ID validation (if present in path)
* 5. VIEW_ID validation (if present in path)
* 6. COUNTERPARTY_ID validation (if present in path)
*/
object ResourceDocMiddleware extends MdcLoggable{
@ -36,15 +36,20 @@ object ResourceDocMiddleware extends MdcLoggable{
private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json)
/**
* Check if ResourceDoc requires authentication based on errorResponseBodies
* Check if ResourceDoc requires authentication based on errorResponseBodies or property
*/
private def needsAuthentication(resourceDoc: ResourceDoc): Boolean = {
// Roles always require an authenticated user to validate entitlements
resourceDoc.errorResponseBodies.contains($AuthenticatedUserIsRequired) || resourceDoc.roles.exists(_.nonEmpty)
// Special handling for resource-docs endpoint
if (resourceDoc.partialFunctionName == "getResourceDocsObpV700") {
APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false)
} else {
// Standard check: roles always require an authenticated user to validate entitlements
resourceDoc.errorResponseBodies.contains($AuthenticatedUserIsRequired) || resourceDoc.roles.exists(_.nonEmpty)
}
}
/**
* Create middleware that applies ResourceDoc-driven validation
* Create middleware that applies ResourceDoc-driven validation to standard HttpRoutes
*/
def apply(resourceDocs: ArrayBuffer[ResourceDoc]): Middleware[IO] = { routes =>
Kleisli[HttpF, Request[IO], Response[IO]] { req =>
@ -67,7 +72,7 @@ object ResourceDocMiddleware extends MdcLoggable{
case Some(resourceDoc) =>
val ccWithDoc = ResourceDocMatcher.attachToCallContext(cc, resourceDoc)
val pathParams = ResourceDocMatcher.extractPathParams(req.uri.path, resourceDoc)
runValidationChain(req, resourceDoc, ccWithDoc, pathParams, routes)
runValidationChainForRoutes(req, resourceDoc, ccWithDoc, pathParams, routes)
.map(ensureJsonContentType)
case None =>
routes.run(req).getOrElseF(IO.pure(Response[IO](org.http4s.Status.NotFound)))
@ -83,9 +88,181 @@ object ResourceDocMiddleware extends MdcLoggable{
}
/**
* Run the validation chain in order: auth bank account view roles counterparty
* Run validation chain and return enriched CallContext.
* Used by wrapEndpoint to validate and enrich CallContext before passing to endpoint.
*/
private def runValidationChain(
resourceDoc: ResourceDoc,
cc: CallContext,
pathParams: Map[String, String]
): IO[CallContext] = {
import com.openbankproject.commons.ExecutionContext.Implicits.global
// Step 1: Authentication
val needsAuth = needsAuthentication(resourceDoc)
logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth")
val authResult: IO[Either[Throwable, (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 =>
IO.pure(Left(new RuntimeException($AuthenticatedUserIsRequired)))
case LiftFailure(msg, _, _) =>
IO.pure(Left(new RuntimeException(msg)))
}
case Left(e: APIFailureNewStyle) =>
IO.pure(Left(e))
case Left(e) =>
IO.pure(Left(new RuntimeException($AuthenticatedUserIsRequired)))
}
} 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 access, continue with Empty user
IO.pure(Right((Empty, cc)))
}
}
authResult.flatMap {
case Left(error) => IO.raiseError(error)
case Right((boxUser, cc1)) =>
// Step 2: Role authorization
val rolesResult: IO[Either[Throwable, CallContext]] =
resourceDoc.roles match {
case Some(roles) if roles.nonEmpty =>
val shouldCheckRoles = if (resourceDoc.partialFunctionName == "getResourceDocsObpV700") {
APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false)
} else {
true
}
if (shouldCheckRoles) {
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 IO.pure(Left(new RuntimeException(UserHasMissingRoles + roles.mkString(", "))))
case _ =>
IO.pure(Left(new RuntimeException($AuthenticatedUserIsRequired)))
}
} else {
IO.pure(Right(cc1))
}
case _ => IO.pure(Right(cc1))
}
rolesResult.flatMap {
case Left(error) => IO.raiseError(error)
case Right(cc2) =>
// Step 3: Bank validation
val bankResult: IO[Either[Throwable, (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) =>
IO.pure(Left(e))
case Left(e) =>
IO.pure(Left(new RuntimeException(BankNotFound + ": " + bankIdStr)))
}
case None => IO.pure(Right((None, cc2)))
}
bankResult.flatMap {
case Left(error) => IO.raiseError(error)
case Right((bankOpt, cc3)) =>
// Step 4: Account validation
val accountResult: IO[Either[Throwable, (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) =>
IO.pure(Left(e))
case Left(e) =>
IO.pure(Left(new RuntimeException(BankAccountNotFound + s": bankId=$bankIdStr, accountId=$accountIdStr")))
}
case _ => IO.pure(Right((None, cc3)))
}
accountResult.flatMap {
case Left(error) => IO.raiseError(error)
case Right((accountOpt, cc4)) =>
// Step 5: View validation
val viewResult: IO[Either[Throwable, (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) =>
IO.pure(Left(e))
case Left(e) =>
IO.pure(Left(new RuntimeException(UserNoPermissionAccessView + s": viewId=$viewIdStr")))
}
case _ => IO.pure(Right((None, cc4)))
}
viewResult.flatMap {
case Left(error) => IO.raiseError(error)
case Right((viewOpt, cc5)) =>
// Step 6: Counterparty validation
val counterpartyResult: IO[Either[Throwable, (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) =>
IO.pure(Left(e))
case Left(e) =>
IO.pure(Left(new RuntimeException(CounterpartyNotFound + s": counterpartyId=$counterpartyIdStr")))
}
case _ => IO.pure(Right((None, cc5)))
}
counterpartyResult.flatMap {
case Left(error) => IO.raiseError(error)
case Right((counterpartyOpt, finalCC)) =>
// All validations passed - return enriched CallContext
val enrichedCC = finalCC.copy(
bank = bankOpt,
bankAccount = accountOpt,
view = viewOpt,
counterparty = counterpartyOpt
)
IO.pure(enrichedCC)
}
}
}
}
}
}
}
/**
* Run validation chain for standard HttpRoutes (returns Response).
* Used by apply() middleware for backward compatibility.
*/
private def runValidationChainForRoutes(
req: Request[IO],
resourceDoc: ResourceDoc,
cc: CallContext,

View File

@ -105,15 +105,16 @@ object Http4s700 {
// Route: GET /obp/v7.0.0/banks
val getBanks: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "banks" =>
val response = for {
cc <- IO.fromOption(req.attributes.lookup(Http4sRequestAttributes.callContextKey))(new RuntimeException("CallContext not found in request attributes"))
result <- IO.fromFuture(IO {
for {
(banks, callContext) <- NewStyle.function.getBanks(Some(cc))
} yield convertAnyToJsonString(JSONFactory400.createBanksJson(banks))
})
} yield result
Ok(response)
Http4sRequestAttributes.withCallContext(req) { cc =>
for {
result <- IO.fromFuture(IO {
for {
(banks, callContext) <- NewStyle.function.getBanks(Some(cc))
} yield convertAnyToJsonString(JSONFactory400.createBanksJson(banks))
})
response <- Ok(result)
} yield response
}
}
resourceDocs += ResourceDoc(
@ -135,16 +136,17 @@ object Http4s700 {
// Authentication handled by ResourceDocMiddleware based on AuthenticatedUserIsRequired
val getCards: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "cards" =>
val response = for {
cc <- IO.fromOption(req.attributes.lookup(Http4sRequestAttributes.callContextKey))(new RuntimeException("CallContext not found in request attributes"))
user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext"))
result <- IO.fromFuture(IO {
for {
(cards, callContext) <- NewStyle.function.getPhysicalCardsForUser(user, Some(cc))
} yield convertAnyToJsonString(JSONFactory1_3_0.createPhysicalCardsJSON(cards, user))
})
} yield result
Ok(response)
Http4sRequestAttributes.withCallContext(req) { cc =>
for {
user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext"))
result <- IO.fromFuture(IO {
for {
(cards, callContext) <- NewStyle.function.getPhysicalCardsForUser(user, Some(cc))
} yield convertAnyToJsonString(JSONFactory1_3_0.createPhysicalCardsJSON(cards, user))
})
response <- Ok(result)
} yield response
}
}
resourceDocs += ResourceDoc(
@ -167,19 +169,20 @@ object Http4s700 {
// Authentication and bank validation handled by ResourceDocMiddleware
val getCardsForBank: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "banks" / bankId / "cards" =>
val response = for {
cc <- IO.fromOption(req.attributes.lookup(Http4sRequestAttributes.callContextKey))(new RuntimeException("CallContext not found in request attributes"))
user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext"))
bank <- IO.fromOption(cc.bank)(new RuntimeException("Bank not found in CallContext"))
result <- IO.fromFuture(IO {
for {
httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString)
(obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(httpParams, Some(cc))
(cards, callContext) <- NewStyle.function.getPhysicalCardsForBank(bank, user, obpQueryParams, callContext)
} yield convertAnyToJsonString(JSONFactory1_3_0.createPhysicalCardsJSON(cards, user))
})
} yield result
Ok(response)
Http4sRequestAttributes.withCallContext(req) { cc =>
for {
user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext"))
bank <- IO.fromOption(cc.bank)(new RuntimeException("Bank not found in CallContext"))
result <- IO.fromFuture(IO {
for {
httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString)
(obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(httpParams, Some(cc))
(cards, callContext) <- NewStyle.function.getPhysicalCardsForBank(bank, user, obpQueryParams, callContext)
} yield convertAnyToJsonString(JSONFactory1_3_0.createPhysicalCardsJSON(cards, user))
})
response <- Ok(result)
} yield response
}
}
resourceDocs += ResourceDoc(
@ -217,88 +220,47 @@ object Http4s700 {
val getResourceDocsObpV700: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "resource-docs" / requestedApiVersionString / "obp" =>
import com.openbankproject.commons.ExecutionContext.Implicits.global
val response = for {
cc <- IO.fromOption(req.attributes.lookup(Http4sRequestAttributes.callContextKey))(new RuntimeException("CallContext not found in request attributes"))
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)
Http4sRequestAttributes.withCallContext(req) { cc =>
for {
result <- IO.fromFuture(IO {
// Check resource_docs_requires_role property
val resourceDocsRequireRole = getPropsAsBoolValue("resource_docs_requires_role", false)
// 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)
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)
})
response <- Ok(result)
} yield response
}
}
// 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(getCounterpartyByIdWithMiddleware),
// "GET",
// "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID",
// "Get Counterparty by Id (http4s with middleware)",
// s"""Get counterparty by id with automatic validation via ResourceDocMiddleware.
// |
// |This endpoint demonstrates the COMPLETE 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)
// |* Counterparty existence validation (COUNTERPARTY_ID in path)
// |
// |${userAuthenticationMessage(true)}""",
// EmptyBody,
// moderatedAccountJSON,
// List(AuthenticatedUserIsRequired, BankNotFound, BankAccountNotFound, ViewNotFound, UserNoPermissionAccessView, CounterpartyNotFound, UnknownError),
// apiTagCounterparty :: Nil,
// http4sPartialFunction = Some(getCounterpartyByIdWithMiddleware)
// )
// // Route: GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID
// // When used with ResourceDocMiddleware, validation is automatic
// val getCounterpartyByIdWithMiddleware: HttpRoutes[IO] = HttpRoutes.of[IO] {
// case req @ GET -> `prefixPath` / "banks" / bankId / "accounts" / accountId / viewId / "counterparties" / counterpartyId =>
// val responseJson = convertAnyToJsonString(
// Map(
// "bank_id" -> bankId,
// "account_id" -> accountId,
// "view_id" -> viewId,
// "counterparty_id" -> counterpartyId
// )
// )
// Ok(responseJson)
// }
// All routes combined (without middleware - for direct use)
val allRoutes: HttpRoutes[IO] =
@ -308,7 +270,6 @@ object Http4s700 {
.orElse(getCards(req))
.orElse(getCardsForBank(req))
.orElse(getResourceDocsObpV700(req))
// .orElse(getAccountByIdWithMiddleware(req))
}
// Routes wrapped with ResourceDocMiddleware for automatic validation