From c2e225471c737b5b495cc61d0d5f22b17d6b0910 Mon Sep 17 00:00:00 2001 From: Simon Redfern Date: Tue, 13 Jan 2026 11:54:00 +0100 Subject: [PATCH 1/3] feature/(Http4s700): set JSON content type for API responses - Add `Content-Type: application/json` header to all API response mappings in Http4s700 - Use a shared `jsonContentType` value for consistent configuration across routes --- obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 1f8388ebd..fea559e55 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -18,6 +18,7 @@ import net.liftweb.json.JsonAST.prettyRender import net.liftweb.json.{Extraction, Formats} import org.http4s._ import org.http4s.dsl.io._ +import org.http4s.headers._ import org.typelevel.vault.Key import scala.collection.mutable.ArrayBuffer @@ -54,6 +55,7 @@ object Http4s700 { // Common prefix: /obp/v7.0.0 val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json) resourceDocs += ResourceDoc( @@ -88,7 +90,7 @@ object Http4s700 { JSONFactory700.getApiInfoJSON(implementedInApiVersion, s"Hello, ${callContext.userId}! Your request ID is ${callContext.requestId}.") ) } - ))) + ))).map(_.withContentType(jsonContentType)) } resourceDocs += ResourceDoc( @@ -123,7 +125,7 @@ object Http4s700 { } yield { convertAnyToJsonString(JSONFactory400.createBanksJson(banks)) } - ))) + ))).map(_.withContentType(jsonContentType)) } val getResourceDocsObpV700: HttpRoutes[IO] = HttpRoutes.of[IO] { @@ -143,7 +145,7 @@ object Http4s700 { filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions) resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam) } yield convertAnyToJsonString(resourceDocsJson) - Ok(IO.fromFuture(IO(logic))) + Ok(IO.fromFuture(IO(logic))).map(_.withContentType(jsonContentType)) } // All routes combined From 59ae64b4a0577869b82001eacbe4c4c79944db61 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 15 Jan 2026 10:41:57 +0100 Subject: [PATCH 2/3] rafactor/(.gitignore): add `.kiro` to ignored files list --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7e1e1bd93..1b8d28dff 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ .zed .cursor .trae +.kiro .classpath .project .cache @@ -44,4 +45,4 @@ project/project coursier metals.sbt obp-http4s-runner/src/main/resources/git.properties -test-results \ No newline at end of file +test-results From f58fb77c5d3230d39f6d54cba457aa769b4abf36 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 15 Jan 2026 11:08:14 +0100 Subject: [PATCH 3/3] 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 --- .../main/scala/code/api/util/APIUtil.scala | 45 +- .../scala/code/api/v7_0_0/Http4s700.scala | 267 ++++++-- .../api/v7_0_0/Http4sResourceDocSupport.scala | 644 ++++++++++++++++++ 3 files changed, 895 insertions(+), 61 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v7_0_0/Http4sResourceDocSupport.scala diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 381b0c283..1847a4e70 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -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) diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index fea559e55..8f1141cbc 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -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 } diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4sResourceDocSupport.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4sResourceDocSupport.scala new file mode 100644 index 000000000..1ea1f1d5d --- /dev/null +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4sResourceDocSupport.scala @@ -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)) + ) + } + } + } + } + } + } + } +}