refactor/(http4s): improve documentation and code clarity in error handling and support utilities

- Enhance ErrorResponseConverter documentation with detailed handler descriptions and response format details
- Add comprehensive comments explaining error type handling (APIFailureNewStyle, Box Failure, unknown exceptions)
- Document correlation-Id header inclusion and HTTP status code mapping in error responses
- Simplify error matching logic in toHttp4sResponse using pattern matching
- Improve Http4sSupport file documentation with clear component descriptions
- Add usage examples for RequestOps implicit class in endpoint implementations
- Clarify CallContext storage mechanism using http4s Vault (type-safe key-value store)
- Document validated entity storage (user, bank, bankAccount, view, counterparty) within CallContext
- Add inline comments explaining ResourceDocMatcher functionality and request matching process
- Improve code readability with consistent formatting and clearer method documentation
This commit is contained in:
hongwei 2026-01-22 16:19:53 +01:00
parent 83d90bfc44
commit 0415d13b1a
3 changed files with 121 additions and 298 deletions

View File

@ -13,7 +13,16 @@ import org.typelevel.ci.CIString
/**
* Converts OBP errors to http4s Response[IO].
* Uses Lift JSON for serialization (consistent with OBP codebase).
*
* Handles:
* - APIFailureNewStyle (structured errors with code and message)
* - Box Failure (Lift framework errors)
* - Unknown exceptions
*
* All responses include:
* - JSON body with code and message
* - Correlation-Id header for request tracing
* - Appropriate HTTP status code
*/
object ErrorResponseConverter {
import net.liftweb.json.Formats
@ -23,7 +32,7 @@ object ErrorResponseConverter {
private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json)
/**
* OBP standard error response format
* OBP standard error response format.
*/
case class OBPErrorResponse(
code: Int,
@ -31,7 +40,7 @@ object ErrorResponseConverter {
)
/**
* Convert error response to JSON string
* Convert error response to JSON string using Lift JSON.
*/
private def toJsonString(error: OBPErrorResponse): String = {
val json = ("code" -> error.code) ~ ("message" -> error.message)
@ -39,19 +48,18 @@ object ErrorResponseConverter {
}
/**
* Convert an error to http4s Response[IO]
* Convert any error to http4s Response[IO].
*/
def toHttp4sResponse(error: Throwable, callContext: CallContext): IO[Response[IO]] = {
error match {
case e: APIFailureNewStyle =>
apiFailureToResponse(e, callContext)
case e =>
unknownErrorToResponse(e, callContext)
case e: APIFailureNewStyle => apiFailureToResponse(e, callContext)
case _ => unknownErrorToResponse(error, callContext)
}
}
/**
* Convert APIFailureNewStyle to http4s Response
* Convert APIFailureNewStyle to http4s Response.
* Uses failCode as HTTP status and failMsg as error message.
*/
def apiFailureToResponse(failure: APIFailureNewStyle, callContext: CallContext): IO[Response[IO]] = {
val errorJson = OBPErrorResponse(failure.failCode, failure.failMsg)
@ -65,7 +73,8 @@ object ErrorResponseConverter {
}
/**
* Convert Box Failure to http4s Response
* Convert Lift Box Failure to http4s Response.
* Returns 400 Bad Request with failure message.
*/
def boxFailureToResponse(failure: LiftFailure, callContext: CallContext): IO[Response[IO]] = {
val errorJson = OBPErrorResponse(400, failure.msg)
@ -78,7 +87,8 @@ object ErrorResponseConverter {
}
/**
* Convert unknown error to http4s Response
* Convert unknown error to http4s Response.
* Returns 500 Internal Server Error.
*/
def unknownErrorToResponse(e: Throwable, callContext: CallContext): IO[Response[IO]] = {
val errorJson = OBPErrorResponse(500, s"$UnknownError: ${e.getMessage}")
@ -91,7 +101,7 @@ object ErrorResponseConverter {
}
/**
* Create error response with specific status code and message
* Create error response with specific status code and message.
*/
def createErrorResponse(statusCode: Int, message: String, callContext: CallContext): IO[Response[IO]] = {
val errorJson = OBPErrorResponse(statusCode, message)

View File

@ -22,57 +22,56 @@ import scala.concurrent.Future
import scala.language.higherKinds
/**
* Http4s support for ResourceDoc-driven validation.
* Http4s support utilities for OBP API.
*
* This file contains:
* - Http4sCallContextBuilder: Builds shared CallContext from http4s Request[IO]
* - Http4sRequestAttributes: Provides CallContext access from http4s requests
* - ResourceDocMatcher: Matches http4s requests to ResourceDoc entries
* This file contains three main components:
*
* Validated entities (User, Bank, BankAccount, View, Counterparty) are stored
* directly in CallContext fields, making them available throughout the call chain.
* 1. Http4sRequestAttributes: Request attribute management and endpoint helpers
* - Stores CallContext in http4s request Vault
* - Provides helper methods to simplify endpoint implementations
* - Validated entities are stored in CallContext fields
*
* 2. Http4sCallContextBuilder: Builds CallContext from http4s Request[IO]
* - Extracts headers, auth params, and request metadata
* - Supports DirectLogin, OAuth, and Gateway authentication
*
* 3. ResourceDocMatcher: Matches requests to ResourceDoc entries
* - Finds ResourceDoc by HTTP verb and URL pattern
* - Extracts path parameters (BANK_ID, ACCOUNT_ID, etc.)
* - Attaches ResourceDoc to CallContext for metrics/rate limiting
*/
/**
* Request attribute keys and helpers for accessing CallContext in http4s requests.
* Request attributes and helper methods for http4s endpoints.
*
* CallContext is stored in http4s request attributes using Vault (type-safe key-value store).
* Validated entities (bank, bankAccount, view, counterparty) are stored within CallContext itself.
*
* Usage in endpoints:
* {{{
* import Http4sRequestAttributes.RequestOps
*
* val myEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] {
* case req @ GET -> Root / "banks" =>
* implicit val cc: CallContext = req.callContext
* for {
* result <- yourBusinessLogic // cc is implicitly available
* response <- Ok(result)
* } yield response
* }
* }}}
* CallContext is stored in request attributes using http4s Vault (type-safe key-value store).
* Validated entities (user, bank, bankAccount, view, counterparty) are stored within CallContext.
*/
object Http4sRequestAttributes {
import org.typelevel.vault.Key
/**
* Vault key for storing CallContext in http4s request attributes.
* CallContext contains all request data and validated entities.
* CallContext contains request data and validated entities (user, bank, account, view, counterparty).
*/
val callContextKey: Key[CallContext] =
Key.newKey[IO, CallContext].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
/**
* Implicit class that adds CallContext accessor to Request[IO].
* Import RequestOps to enable `req.callContext` syntax.
* Implicit class that adds .callContext accessor to Request[IO].
*
* Usage:
* {{{
* import Http4sRequestAttributes.RequestOps
*
* case req @ GET -> Root / "banks" =>
* implicit val cc: CallContext = req.callContext
* // Use cc for business logic
* }}}
*/
implicit class RequestOps(val req: Request[IO]) extends AnyVal {
/**
* Extract CallContext from request attributes.
* Throws RuntimeException if CallContext is not found (should never happen with ResourceDocMiddleware).
*
* @return CallContext containing validated user, bank, account, view, counterparty
* Throws RuntimeException if not found (should never happen with ResourceDocMiddleware).
*/
def callContext: CallContext = {
req.attributes.lookup(callContextKey).getOrElse(
@ -82,31 +81,26 @@ object Http4sRequestAttributes {
}
/**
* Helper methods to simplify endpoint implementations.
* These eliminate boilerplate for common patterns in http4s endpoints.
* Helper methods to eliminate boilerplate in endpoint implementations.
*
* These methods handle:
* - CallContext extraction from request
* - User/Bank extraction from CallContext
* - Future execution with IO.fromFuture
* - JSON serialization with Lift JSON
* - Ok response creation
*/
object EndpointHelpers {
import net.liftweb.json.{Extraction, Formats}
import net.liftweb.json.JsonAST.prettyRender
/**
* Execute a Future-based business logic function and return JSON response.
* Handles Future execution, JSON conversion, and Ok response creation.
* Execute Future-based business logic and return JSON response.
*
* Usage:
* {{{
* case req @ GET -> Root / "banks" =>
* executeAndRespond(req) { implicit cc =>
* for {
* (banks, callContext) <- NewStyle.function.getBanks(Some(cc))
* } yield JSONFactory400.createBanksJson(banks)
* }
* }}}
* Handles: Future execution, JSON conversion, Ok response.
*
* @param req The http4s request
* @param f Business logic function that takes CallContext and returns Future[A]
* @param formats Implicit JSON formats for serialization
* @tparam A The result type (will be converted to JSON)
* @param req http4s request
* @param f Business logic: CallContext => Future[A]
* @return IO[Response[IO]] with JSON body
*/
def executeAndRespond[A](req: Request[IO])(f: CallContext => Future[A])(implicit formats: Formats): IO[Response[IO]] = {
@ -119,23 +113,12 @@ object Http4sRequestAttributes {
}
/**
* Execute business logic that requires validated User from CallContext.
* Extracts User from CallContext, executes business logic, and returns JSON response.
* Execute business logic requiring validated User.
*
* Usage:
* {{{
* case req @ GET -> Root / "cards" =>
* withUser(req) { (user, cc) =>
* for {
* (cards, callContext) <- NewStyle.function.getPhysicalCardsForUser(user, Some(cc))
* } yield JSONFactory1_3_0.createPhysicalCardsJSON(cards, user)
* }
* }}}
* Extracts User from CallContext, executes logic, returns JSON response.
*
* @param req The http4s request
* @param f Business logic function that takes (User, CallContext) and returns Future[A]
* @param formats Implicit JSON formats for serialization
* @tparam A The result type (will be converted to JSON)
* @param req http4s request
* @param f Business logic: (User, CallContext) => Future[A]
* @return IO[Response[IO]] with JSON body
*/
def withUser[A](req: Request[IO])(f: (User, CallContext) => Future[A])(implicit formats: Formats): IO[Response[IO]] = {
@ -149,23 +132,12 @@ object Http4sRequestAttributes {
}
/**
* Execute business logic that requires validated Bank from CallContext.
* Extracts Bank from CallContext, executes business logic, and returns JSON response.
* Execute business logic requiring validated Bank.
*
* Usage:
* {{{
* case req @ GET -> Root / "banks" / bankId / "accounts" =>
* withBank(req) { (bank, cc) =>
* for {
* (accounts, callContext) <- NewStyle.function.getBankAccounts(bank, Some(cc))
* } yield JSONFactory400.createAccountsJson(accounts)
* }
* }}}
* Extracts Bank from CallContext, executes logic, returns JSON response.
*
* @param req The http4s request
* @param f Business logic function that takes (Bank, CallContext) and returns Future[A]
* @param formats Implicit JSON formats for serialization
* @tparam A The result type (will be converted to JSON)
* @param req http4s request
* @param f Business logic: (Bank, CallContext) => Future[A]
* @return IO[Response[IO]] with JSON body
*/
def withBank[A](req: Request[IO])(f: (Bank, CallContext) => Future[A])(implicit formats: Formats): IO[Response[IO]] = {
@ -179,23 +151,12 @@ object Http4sRequestAttributes {
}
/**
* Execute business logic that requires both User and Bank from CallContext.
* Extracts both from CallContext, executes business logic, and returns JSON response.
* Execute business logic requiring both User and Bank.
*
* Usage:
* {{{
* case req @ GET -> Root / "banks" / bankId / "cards" =>
* withUserAndBank(req) { (user, bank, cc) =>
* for {
* (cards, callContext) <- NewStyle.function.getPhysicalCardsForBank(bank, user, obpQueryParams, Some(cc))
* } yield JSONFactory1_3_0.createPhysicalCardsJSON(cards, user)
* }
* }}}
* Extracts both from CallContext, executes logic, returns JSON response.
*
* @param req The http4s request
* @param f Business logic function that takes (User, Bank, CallContext) and returns Future[A]
* @param formats Implicit JSON formats for serialization
* @tparam A The result type (will be converted to JSON)
* @param req http4s request
* @param f Business logic: (User, Bank, CallContext) => Future[A]
* @return IO[Response[IO]] with JSON body
*/
def withUserAndBank[A](req: Request[IO])(f: (User, Bank, CallContext) => Future[A])(implicit formats: Formats): IO[Response[IO]] = {

View File

@ -20,14 +20,17 @@ 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 first
* 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)
* 1. Authentication - Check if user is authenticated (if required by ResourceDoc)
* 2. Authorization - Verify user has required roles/entitlements
* 3. Bank validation - Validate BANK_ID path parameter (if present)
* 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{
@ -36,20 +39,26 @@ object ResourceDocMiddleware extends MdcLoggable{
private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json)
/**
* Check if ResourceDoc requires authentication based on errorResponseBodies or property
* Check if ResourceDoc requires authentication.
*
* Authentication is required if:
* - ResourceDoc errorResponseBodies contains $AuthenticatedUserIsRequired
* - ResourceDoc has roles (roles always require authenticated user)
* - Special case: resource-docs endpoint checks resource_docs_requires_role property
*/
private def needsAuthentication(resourceDoc: ResourceDoc): Boolean = {
// 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 to standard HttpRoutes
* Create middleware that applies ResourceDoc-driven validation.
*
* @param resourceDocs Collection of ResourceDoc entries for matching
* @return Middleware that wraps HttpRoutes with validation
*/
def apply(resourceDocs: ArrayBuffer[ResourceDoc]): Middleware[IO] = { routes =>
Kleisli[HttpF, Request[IO], Response[IO]] { req =>
@ -58,7 +67,13 @@ object ResourceDocMiddleware extends MdcLoggable{
}
/**
* Validate request and route to handler if validation passes
* 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],
@ -80,6 +95,9 @@ object ResourceDocMiddleware extends MdcLoggable{
} yield response
}
/**
* Ensure 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
@ -88,179 +106,18 @@ object ResourceDocMiddleware extends MdcLoggable{
}
/**
* 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.
* 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],
@ -270,10 +127,10 @@ object ResourceDocMiddleware extends MdcLoggable{
routes: HttpRoutes[IO]
): IO[Response[IO]] = {
// Step 1: Authentication
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 {
@ -305,24 +162,19 @@ object ResourceDocMiddleware extends MdcLoggable{
} else {
IO.fromFuture(IO(APIUtil.anonymousAccess(cc))).attempt.flatMap {
case Right((boxUser, Some(updatedCC))) =>
logger.debug(s"[ResourceDocMiddleware] anonymousAccess succeeded with user: $boxUser")
IO.pure(Right((boxUser, updatedCC)))
case Right((boxUser, None)) =>
logger.debug(s"[ResourceDocMiddleware] anonymousAccess succeeded with user: $boxUser (no updated CC)")
IO.pure(Right((boxUser, cc)))
case Left(e) =>
// For anonymous access, we don't fail on auth errors - just continue with Empty user
// This allows endpoints without $AuthenticatedUserIsRequired to work without authentication
logger.debug(s"[ResourceDocMiddleware] anonymousAccess threw exception (ignoring for anonymous): ${e.getClass.getName}: ${e.getMessage.take(100)}")
// For anonymous endpoints, continue with Empty user even if auth fails
IO.pure(Right((Empty, cc)))
}
}
authResult.flatMap {
authResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right((boxUser, cc1)) =>
// Step 2: Role authorization - BEFORE business logic validation
// Step 2: Role authorization
val rolesResult: IO[Either[Response[IO], CallContext]] =
resourceDoc.roles match {
case Some(roles) if roles.nonEmpty =>