mirror of
https://github.com/OpenBankProject/OBP-API.git
synced 2026-02-06 11:06:49 +00:00
feature/ (http4s): add comprehensive Http4s utilities and middleware support
- Add ErrorResponseConverter for converting OBP errors to http4s Response[IO] - Add Http4sSupport with CallContext builder and vault keys for request attributes - Add ResourceDocMiddleware for validation chain middleware in http4s - Add Http4sSupport package object with utility functions and type aliases - Update Http4s700 to integrate new middleware and error handling utilities - Remove Http4sResourceDocSupport in favor of consolidated Http4sSupport module - Consolidate Http4s-related utilities into dedicated util/http4s package for better organization and reusability
This commit is contained in:
parent
f58fb77c5d
commit
2c9af4e851
@ -0,0 +1,106 @@
|
||||
package code.api.util.http4s
|
||||
|
||||
import cats.effect._
|
||||
import code.api.APIFailureNewStyle
|
||||
import code.api.util.ErrorMessages._
|
||||
import code.api.util.{CallContext => SharedCallContext}
|
||||
import net.liftweb.common.{Failure => LiftFailure}
|
||||
import net.liftweb.json.compactRender
|
||||
import net.liftweb.json.JsonDSL._
|
||||
import org.http4s._
|
||||
import org.http4s.headers.`Content-Type`
|
||||
import org.typelevel.ci.CIString
|
||||
|
||||
/**
|
||||
* 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))
|
||||
)
|
||||
}
|
||||
}
|
||||
304
obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala
Normal file
304
obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala
Normal file
@ -0,0 +1,304 @@
|
||||
package code.api.util.http4s
|
||||
|
||||
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
|
||||
)
|
||||
@ -0,0 +1,258 @@
|
||||
package code.api.util.http4s
|
||||
|
||||
import cats.data.{Kleisli, OptionT}
|
||||
import cats.effect._
|
||||
import code.api.APIFailureNewStyle
|
||||
import code.api.util.APIUtil
|
||||
import code.api.util.APIUtil.ResourceDoc
|
||||
import code.api.util.ErrorMessages._
|
||||
import code.api.util.NewStyle
|
||||
import code.api.util.newstyle.ViewNewStyle
|
||||
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 org.http4s._
|
||||
|
||||
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:
|
||||
* - 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 {
|
||||
|
||||
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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
obp-api/src/main/scala/code/api/util/http4s/package.scala
Normal file
34
obp-api/src/main/scala/code/api/util/http4s/package.scala
Normal file
@ -0,0 +1,34 @@
|
||||
package code.api.util
|
||||
|
||||
/**
|
||||
* Http4s support package for OBP API.
|
||||
*
|
||||
* This package provides http4s-specific utilities for:
|
||||
* - Building CallContext from http4s requests
|
||||
* - Storing validated objects in request attributes (Vault keys)
|
||||
* - Matching requests to ResourceDoc entries
|
||||
* - ResourceDoc-driven validation middleware
|
||||
* - Error response conversion
|
||||
*
|
||||
* Usage:
|
||||
* {{{
|
||||
* import code.api.util.http4s._
|
||||
*
|
||||
* // Build CallContext from request
|
||||
* val cc = Http4sCallContextBuilder.fromRequest(request, "v7.0.0")
|
||||
*
|
||||
* // Access validated objects from request attributes
|
||||
* val user = Http4sVaultKeys.getUser(request)
|
||||
* val bank = Http4sVaultKeys.getBank(request)
|
||||
*
|
||||
* // Apply middleware to routes
|
||||
* val wrappedRoutes = ResourceDocMiddleware.apply(resourceDocs)(routes)
|
||||
*
|
||||
* // Convert errors to http4s responses
|
||||
* ErrorResponseConverter.unknownErrorToResponse(error, callContext)
|
||||
* }}}
|
||||
*/
|
||||
package object http4s {
|
||||
// Re-export types for convenience
|
||||
type SharedCallContext = code.api.util.CallContext
|
||||
}
|
||||
@ -10,6 +10,7 @@ import code.api.util.ApiTag._
|
||||
import code.api.util.ErrorMessages._
|
||||
import code.api.util.{ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle}
|
||||
import code.api.util.ApiRole.canReadResourceDoc
|
||||
import code.api.util.http4s.{Http4sCallContextBuilder, Http4sVaultKeys, ResourceDocMiddleware, ErrorResponseConverter}
|
||||
import code.api.v1_4_0.JSONFactory1_4_0
|
||||
import code.api.v4_0_0.JSONFactory400
|
||||
import com.github.dwickern.macros.NameOf.nameOf
|
||||
|
||||
@ -1,644 +0,0 @@
|
||||
package code.api.v7_0_0
|
||||
|
||||
import cats.effect._
|
||||
import code.api.APIFailureNewStyle
|
||||
import code.api.util.APIUtil.ResourceDoc
|
||||
import code.api.util.ErrorMessages._
|
||||
import code.api.util.{CallContext => SharedCallContext}
|
||||
import com.openbankproject.commons.model.{Bank, BankAccount, BankId, AccountId, ViewId, BankIdAccountId, CounterpartyTrait, User, View}
|
||||
import net.liftweb.common.{Box, Empty, Full, Failure => LiftFailure}
|
||||
import net.liftweb.http.provider.HTTPParam
|
||||
import net.liftweb.json.{Extraction, compactRender}
|
||||
import net.liftweb.json.JsonDSL._
|
||||
import org.http4s._
|
||||
import org.http4s.headers.`Content-Type`
|
||||
import org.typelevel.ci.CIString
|
||||
import org.typelevel.vault.Key
|
||||
|
||||
import java.util.{Date, UUID}
|
||||
import scala.collection.mutable.ArrayBuffer
|
||||
import scala.language.higherKinds
|
||||
|
||||
/**
|
||||
* Http4s support for ResourceDoc-driven validation.
|
||||
*
|
||||
* This file contains:
|
||||
* - Http4sCallContextBuilder: Builds shared CallContext from http4s Request[IO]
|
||||
* - Http4sVaultKeys: Vault keys for storing validated objects in request attributes
|
||||
* - ResourceDocMatcher: Matches http4s requests to ResourceDoc entries
|
||||
* - ResourceDocMiddleware: Validation chain middleware for http4s
|
||||
* - ErrorResponseConverter: Converts OBP errors to http4s Response[IO]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Vault keys for storing validated objects in http4s request attributes.
|
||||
* These keys allow middleware to pass validated objects to endpoint handlers.
|
||||
*/
|
||||
object Http4sVaultKeys {
|
||||
// Use shared CallContext from code.api.util.ApiSession
|
||||
val callContextKey: Key[SharedCallContext] =
|
||||
Key.newKey[IO, SharedCallContext].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
|
||||
|
||||
val userKey: Key[User] =
|
||||
Key.newKey[IO, User].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
|
||||
|
||||
val bankKey: Key[Bank] =
|
||||
Key.newKey[IO, Bank].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
|
||||
|
||||
val bankAccountKey: Key[BankAccount] =
|
||||
Key.newKey[IO, BankAccount].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
|
||||
|
||||
val viewKey: Key[View] =
|
||||
Key.newKey[IO, View].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
|
||||
|
||||
val counterpartyKey: Key[CounterpartyTrait] =
|
||||
Key.newKey[IO, CounterpartyTrait].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
|
||||
|
||||
/**
|
||||
* Helper methods for accessing validated objects from request attributes
|
||||
*/
|
||||
def getCallContext(req: Request[IO]): Option[SharedCallContext] =
|
||||
req.attributes.lookup(callContextKey)
|
||||
|
||||
def getUser(req: Request[IO]): Option[User] =
|
||||
req.attributes.lookup(userKey)
|
||||
|
||||
def getBank(req: Request[IO]): Option[Bank] =
|
||||
req.attributes.lookup(bankKey)
|
||||
|
||||
def getBankAccount(req: Request[IO]): Option[BankAccount] =
|
||||
req.attributes.lookup(bankAccountKey)
|
||||
|
||||
def getView(req: Request[IO]): Option[View] =
|
||||
req.attributes.lookup(viewKey)
|
||||
|
||||
def getCounterparty(req: Request[IO]): Option[CounterpartyTrait] =
|
||||
req.attributes.lookup(counterpartyKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds shared CallContext from http4s Request[IO].
|
||||
*
|
||||
* This builder extracts all necessary request data and populates the shared CallContext,
|
||||
* enabling the existing authentication and validation code to work with http4s requests.
|
||||
*/
|
||||
object Http4sCallContextBuilder {
|
||||
|
||||
/**
|
||||
* Build CallContext from http4s Request[IO]
|
||||
* Populates all fields needed by getUserAndSessionContextFuture
|
||||
*
|
||||
* @param request The http4s request
|
||||
* @param apiVersion The API version string (e.g., "v7.0.0")
|
||||
* @return IO[SharedCallContext] with all request data populated
|
||||
*/
|
||||
def fromRequest(request: Request[IO], apiVersion: String): IO[SharedCallContext] = {
|
||||
for {
|
||||
body <- request.bodyText.compile.string.map(s => if (s.isEmpty) None else Some(s))
|
||||
} yield SharedCallContext(
|
||||
url = request.uri.renderString,
|
||||
verb = request.method.name,
|
||||
implementedInVersion = apiVersion,
|
||||
correlationId = extractCorrelationId(request),
|
||||
ipAddress = extractIpAddress(request),
|
||||
requestHeaders = extractHeaders(request),
|
||||
httpBody = body,
|
||||
authReqHeaderField = extractAuthHeader(request),
|
||||
directLoginParams = extractDirectLoginParams(request),
|
||||
oAuthParams = extractOAuthParams(request),
|
||||
startTime = Some(new Date())
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract headers from http4s request and convert to List[HTTPParam]
|
||||
*/
|
||||
private def extractHeaders(request: Request[IO]): List[HTTPParam] = {
|
||||
request.headers.headers.map { h =>
|
||||
HTTPParam(h.name.toString, List(h.value))
|
||||
}.toList
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract correlation ID from X-Request-ID header or generate a new UUID
|
||||
*/
|
||||
private def extractCorrelationId(request: Request[IO]): String = {
|
||||
request.headers.get(CIString("X-Request-ID"))
|
||||
.map(_.head.value)
|
||||
.getOrElse(UUID.randomUUID().toString)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract IP address from X-Forwarded-For header or request remote address
|
||||
*/
|
||||
private def extractIpAddress(request: Request[IO]): String = {
|
||||
request.headers.get(CIString("X-Forwarded-For"))
|
||||
.map(_.head.value.split(",").head.trim)
|
||||
.orElse(request.remoteAddr.map(_.toUriString))
|
||||
.getOrElse("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Authorization header value as Box[String]
|
||||
*/
|
||||
private def extractAuthHeader(request: Request[IO]): Box[String] = {
|
||||
request.headers.get(CIString("Authorization"))
|
||||
.map(h => Full(h.head.value))
|
||||
.getOrElse(Empty)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract DirectLogin header parameters if present
|
||||
* DirectLogin header format: DirectLogin token="xxx"
|
||||
*/
|
||||
private def extractDirectLoginParams(request: Request[IO]): Map[String, String] = {
|
||||
request.headers.get(CIString("DirectLogin"))
|
||||
.map(h => parseDirectLoginHeader(h.head.value))
|
||||
.getOrElse(Map.empty)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse DirectLogin header value into parameter map
|
||||
* Format: DirectLogin token="xxx", username="yyy"
|
||||
*/
|
||||
private def parseDirectLoginHeader(headerValue: String): Map[String, String] = {
|
||||
val pattern = """(\w+)="([^"]*)"""".r
|
||||
pattern.findAllMatchIn(headerValue).map { m =>
|
||||
m.group(1) -> m.group(2)
|
||||
}.toMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract OAuth parameters from Authorization header if OAuth
|
||||
*/
|
||||
private def extractOAuthParams(request: Request[IO]): Map[String, String] = {
|
||||
request.headers.get(CIString("Authorization"))
|
||||
.filter(_.head.value.startsWith("OAuth "))
|
||||
.map(h => parseOAuthHeader(h.head.value))
|
||||
.getOrElse(Map.empty)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse OAuth Authorization header value into parameter map
|
||||
* Format: OAuth oauth_consumer_key="xxx", oauth_token="yyy", ...
|
||||
*/
|
||||
private def parseOAuthHeader(headerValue: String): Map[String, String] = {
|
||||
val oauthPart = headerValue.stripPrefix("OAuth ").trim
|
||||
val pattern = """(\w+)="([^"]*)"""".r
|
||||
pattern.findAllMatchIn(oauthPart).map { m =>
|
||||
m.group(1) -> m.group(2)
|
||||
}.toMap
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches http4s requests to ResourceDoc entries.
|
||||
*
|
||||
* ResourceDoc entries use URL templates with uppercase variable names:
|
||||
* - BANK_ID, ACCOUNT_ID, VIEW_ID, COUNTERPARTY_ID
|
||||
*
|
||||
* This matcher finds the corresponding ResourceDoc for a given request
|
||||
* and extracts path parameters.
|
||||
*/
|
||||
object ResourceDocMatcher {
|
||||
|
||||
/**
|
||||
* Find ResourceDoc matching the given verb and path
|
||||
*
|
||||
* @param verb HTTP verb (GET, POST, PUT, DELETE, etc.)
|
||||
* @param path Request path
|
||||
* @param resourceDocs Collection of ResourceDoc entries to search
|
||||
* @return Option[ResourceDoc] if a match is found
|
||||
*/
|
||||
def findResourceDoc(
|
||||
verb: String,
|
||||
path: Uri.Path,
|
||||
resourceDocs: ArrayBuffer[ResourceDoc]
|
||||
): Option[ResourceDoc] = {
|
||||
val pathString = path.renderString
|
||||
resourceDocs.find { doc =>
|
||||
doc.requestVerb.equalsIgnoreCase(verb) && matchesUrlTemplate(pathString, doc.requestUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path matches a URL template
|
||||
* Template segments in uppercase are treated as variables
|
||||
*/
|
||||
private def matchesUrlTemplate(path: String, template: String): Boolean = {
|
||||
val pathSegments = path.split("/").filter(_.nonEmpty)
|
||||
val templateSegments = template.split("/").filter(_.nonEmpty)
|
||||
|
||||
if (pathSegments.length != templateSegments.length) {
|
||||
false
|
||||
} else {
|
||||
pathSegments.zip(templateSegments).forall { case (pathSeg, templateSeg) =>
|
||||
// Uppercase segments are variables (BANK_ID, ACCOUNT_ID, etc.)
|
||||
isTemplateVariable(templateSeg) || pathSeg == templateSeg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a template segment is a variable (uppercase)
|
||||
*/
|
||||
private def isTemplateVariable(segment: String): Boolean = {
|
||||
segment.nonEmpty && segment.forall(c => c.isUpper || c == '_' || c.isDigit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path parameters from matched ResourceDoc
|
||||
*
|
||||
* @param path Request path
|
||||
* @param resourceDoc Matched ResourceDoc
|
||||
* @return Map with keys: BANK_ID, ACCOUNT_ID, VIEW_ID, COUNTERPARTY_ID (if present)
|
||||
*/
|
||||
def extractPathParams(
|
||||
path: Uri.Path,
|
||||
resourceDoc: ResourceDoc
|
||||
): Map[String, String] = {
|
||||
val pathString = path.renderString
|
||||
val pathSegments = pathString.split("/").filter(_.nonEmpty)
|
||||
val templateSegments = resourceDoc.requestUrl.split("/").filter(_.nonEmpty)
|
||||
|
||||
if (pathSegments.length != templateSegments.length) {
|
||||
Map.empty
|
||||
} else {
|
||||
pathSegments.zip(templateSegments).collect {
|
||||
case (pathSeg, templateSeg) if isTemplateVariable(templateSeg) =>
|
||||
templateSeg -> pathSeg
|
||||
}.toMap
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update CallContext with matched ResourceDoc
|
||||
* MUST be called after successful match for metrics/rate limiting consistency
|
||||
*
|
||||
* @param callContext Current CallContext
|
||||
* @param resourceDoc Matched ResourceDoc
|
||||
* @return Updated CallContext with resourceDocument and operationId set
|
||||
*/
|
||||
def attachToCallContext(
|
||||
callContext: SharedCallContext,
|
||||
resourceDoc: ResourceDoc
|
||||
): SharedCallContext = {
|
||||
callContext.copy(
|
||||
resourceDocument = Some(resourceDoc),
|
||||
operationId = Some(resourceDoc.operationId)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validated context containing all validated objects from the middleware chain.
|
||||
* This is passed to endpoint handlers after successful validation.
|
||||
*/
|
||||
case class ValidatedContext(
|
||||
user: Option[User],
|
||||
bank: Option[Bank],
|
||||
bankAccount: Option[BankAccount],
|
||||
view: Option[View],
|
||||
counterparty: Option[CounterpartyTrait],
|
||||
callContext: SharedCallContext
|
||||
)
|
||||
|
||||
|
||||
/**
|
||||
* Converts OBP errors to http4s Response[IO].
|
||||
* Uses Lift JSON for serialization (consistent with OBP codebase).
|
||||
*/
|
||||
object ErrorResponseConverter {
|
||||
import net.liftweb.json.Formats
|
||||
import code.api.util.CustomJsonFormats
|
||||
|
||||
implicit val formats: Formats = CustomJsonFormats.formats
|
||||
private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json)
|
||||
|
||||
/**
|
||||
* OBP standard error response format
|
||||
*/
|
||||
case class OBPErrorResponse(
|
||||
code: Int,
|
||||
message: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Convert error response to JSON string
|
||||
*/
|
||||
private def toJsonString(error: OBPErrorResponse): String = {
|
||||
val json = ("code" -> error.code) ~ ("message" -> error.message)
|
||||
compactRender(json)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an error to http4s Response[IO]
|
||||
*/
|
||||
def toHttp4sResponse(error: Throwable, callContext: SharedCallContext): IO[Response[IO]] = {
|
||||
error match {
|
||||
case e: APIFailureNewStyle =>
|
||||
apiFailureToResponse(e, callContext)
|
||||
case e =>
|
||||
unknownErrorToResponse(e, callContext)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert APIFailureNewStyle to http4s Response
|
||||
*/
|
||||
def apiFailureToResponse(failure: APIFailureNewStyle, callContext: SharedCallContext): IO[Response[IO]] = {
|
||||
val errorJson = OBPErrorResponse(failure.failCode, failure.failMsg)
|
||||
val status = org.http4s.Status.fromInt(failure.failCode).getOrElse(org.http4s.Status.BadRequest)
|
||||
IO.pure(
|
||||
Response[IO](status)
|
||||
.withEntity(toJsonString(errorJson))
|
||||
.withContentType(jsonContentType)
|
||||
.putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Box Failure to http4s Response
|
||||
*/
|
||||
def boxFailureToResponse(failure: LiftFailure, callContext: SharedCallContext): IO[Response[IO]] = {
|
||||
val errorJson = OBPErrorResponse(400, failure.msg)
|
||||
IO.pure(
|
||||
Response[IO](org.http4s.Status.BadRequest)
|
||||
.withEntity(toJsonString(errorJson))
|
||||
.withContentType(jsonContentType)
|
||||
.putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert unknown error to http4s Response
|
||||
*/
|
||||
def unknownErrorToResponse(e: Throwable, callContext: SharedCallContext): IO[Response[IO]] = {
|
||||
val errorJson = OBPErrorResponse(500, s"$UnknownError: ${e.getMessage}")
|
||||
IO.pure(
|
||||
Response[IO](org.http4s.Status.InternalServerError)
|
||||
.withEntity(toJsonString(errorJson))
|
||||
.withContentType(jsonContentType)
|
||||
.putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error response with specific status code and message
|
||||
*/
|
||||
def createErrorResponse(statusCode: Int, message: String, callContext: SharedCallContext): IO[Response[IO]] = {
|
||||
val errorJson = OBPErrorResponse(statusCode, message)
|
||||
val status = org.http4s.Status.fromInt(statusCode).getOrElse(org.http4s.Status.BadRequest)
|
||||
IO.pure(
|
||||
Response[IO](status)
|
||||
.withEntity(toJsonString(errorJson))
|
||||
.withContentType(jsonContentType)
|
||||
.putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ResourceDoc-driven validation middleware for http4s.
|
||||
*
|
||||
* This middleware wraps http4s routes with automatic validation based on ResourceDoc metadata:
|
||||
* - Authentication (if required by ResourceDoc)
|
||||
* - Bank existence validation (if BANK_ID in path)
|
||||
* - Role-based authorization (if roles specified in ResourceDoc)
|
||||
* - Account existence validation (if ACCOUNT_ID in path)
|
||||
* - View access validation (if VIEW_ID in path)
|
||||
* - Counterparty existence validation (if COUNTERPARTY_ID in path)
|
||||
*
|
||||
* Validation order matches Lift: auth → bank → roles → account → view → counterparty
|
||||
*/
|
||||
object ResourceDocMiddleware {
|
||||
import cats.data.{Kleisli, OptionT}
|
||||
import code.api.util.APIUtil
|
||||
import code.api.util.NewStyle
|
||||
import code.api.util.newstyle.ViewNewStyle
|
||||
|
||||
type HttpF[A] = OptionT[IO, A]
|
||||
type Middleware[F[_]] = HttpRoutes[F] => HttpRoutes[F]
|
||||
|
||||
/**
|
||||
* Check if ResourceDoc requires authentication based on errorResponseBodies
|
||||
*/
|
||||
private def needsAuthentication(resourceDoc: ResourceDoc): Boolean = {
|
||||
resourceDoc.errorResponseBodies.contains($UserNotLoggedIn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create middleware that applies ResourceDoc-driven validation
|
||||
*
|
||||
* @param resourceDocs Collection of ResourceDoc entries for matching
|
||||
* @return Middleware that wraps routes with validation
|
||||
*/
|
||||
def apply(resourceDocs: ArrayBuffer[ResourceDoc]): Middleware[IO] = { routes =>
|
||||
Kleisli[HttpF, Request[IO], Response[IO]] { req =>
|
||||
OptionT.liftF(validateAndRoute(req, routes, resourceDocs))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate request and route to handler if validation passes
|
||||
*/
|
||||
private def validateAndRoute(
|
||||
req: Request[IO],
|
||||
routes: HttpRoutes[IO],
|
||||
resourceDocs: ArrayBuffer[ResourceDoc]
|
||||
): IO[Response[IO]] = {
|
||||
for {
|
||||
// Build CallContext from request
|
||||
cc <- Http4sCallContextBuilder.fromRequest(req, "v7.0.0")
|
||||
|
||||
// Match ResourceDoc
|
||||
resourceDocOpt = ResourceDocMatcher.findResourceDoc(req.method.name, req.uri.path, resourceDocs)
|
||||
|
||||
response <- resourceDocOpt match {
|
||||
case Some(resourceDoc) =>
|
||||
// Attach ResourceDoc to CallContext for metrics/rate limiting
|
||||
val ccWithDoc = ResourceDocMatcher.attachToCallContext(cc, resourceDoc)
|
||||
val pathParams = ResourceDocMatcher.extractPathParams(req.uri.path, resourceDoc)
|
||||
|
||||
// Run validation chain
|
||||
runValidationChain(req, resourceDoc, ccWithDoc, pathParams, routes)
|
||||
|
||||
case None =>
|
||||
// No matching ResourceDoc - pass through to routes
|
||||
routes.run(req).getOrElseF(
|
||||
IO.pure(Response[IO](org.http4s.Status.NotFound))
|
||||
)
|
||||
}
|
||||
} yield response
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the validation chain in order: auth → bank → roles → account → view → counterparty
|
||||
*/
|
||||
private def runValidationChain(
|
||||
req: Request[IO],
|
||||
resourceDoc: ResourceDoc,
|
||||
cc: SharedCallContext,
|
||||
pathParams: Map[String, String],
|
||||
routes: HttpRoutes[IO]
|
||||
): IO[Response[IO]] = {
|
||||
import com.openbankproject.commons.ExecutionContext.Implicits.global
|
||||
|
||||
// Step 1: Authentication
|
||||
val authResult: IO[Either[Response[IO], (Box[User], SharedCallContext)]] =
|
||||
if (needsAuthentication(resourceDoc)) {
|
||||
IO.fromFuture(IO(APIUtil.authenticatedAccess(cc))).attempt.map {
|
||||
case Right((boxUser, Some(updatedCC))) =>
|
||||
boxUser match {
|
||||
case Full(_) => Right((boxUser, updatedCC))
|
||||
case Empty => Left(Response[IO](org.http4s.Status.Unauthorized))
|
||||
case LiftFailure(_, _, _) => Left(Response[IO](org.http4s.Status.Unauthorized))
|
||||
}
|
||||
case Right((boxUser, None)) => Right((boxUser, cc))
|
||||
case Left(e: APIFailureNewStyle) =>
|
||||
Left(Response[IO](org.http4s.Status.fromInt(e.failCode).getOrElse(org.http4s.Status.Unauthorized)))
|
||||
case Left(_) => Left(Response[IO](org.http4s.Status.Unauthorized))
|
||||
}
|
||||
} else {
|
||||
IO.fromFuture(IO(APIUtil.anonymousAccess(cc))).attempt.map {
|
||||
case Right((boxUser, Some(updatedCC))) => Right((boxUser, updatedCC))
|
||||
case Right((boxUser, None)) => Right((boxUser, cc))
|
||||
case Left(_) => Right((Empty, cc))
|
||||
}
|
||||
}
|
||||
|
||||
authResult.flatMap {
|
||||
case Left(errorResponse) => IO.pure(errorResponse)
|
||||
case Right((boxUser, cc1)) =>
|
||||
// Step 2: Bank validation (if BANK_ID in path)
|
||||
val bankResult: IO[Either[Response[IO], (Option[Bank], SharedCallContext)]] =
|
||||
pathParams.get("BANK_ID") match {
|
||||
case Some(bankIdStr) =>
|
||||
IO.fromFuture(IO(NewStyle.function.getBank(BankId(bankIdStr), Some(cc1)))).attempt.map {
|
||||
case Right((bank, Some(updatedCC))) => Right((Some(bank), updatedCC))
|
||||
case Right((bank, None)) => Right((Some(bank), cc1))
|
||||
case Left(_: APIFailureNewStyle) =>
|
||||
Left(Response[IO](org.http4s.Status.NotFound))
|
||||
case Left(_) => Left(Response[IO](org.http4s.Status.NotFound))
|
||||
}
|
||||
case None => IO.pure(Right((None, cc1)))
|
||||
}
|
||||
|
||||
bankResult.flatMap {
|
||||
case Left(errorResponse) => IO.pure(errorResponse)
|
||||
case Right((bankOpt, cc2)) =>
|
||||
// Step 3: Role authorization (if roles specified)
|
||||
val rolesResult: IO[Either[Response[IO], SharedCallContext]] =
|
||||
resourceDoc.roles match {
|
||||
case Some(roles) if roles.nonEmpty && boxUser.isDefined =>
|
||||
val userId = boxUser.map(_.userId).getOrElse("")
|
||||
val bankId = bankOpt.map(_.bankId.value).getOrElse("")
|
||||
|
||||
// Check if user has at least one of the required roles
|
||||
val hasRole = roles.exists { role =>
|
||||
val checkBankId = if (role.requiresBankId) bankId else ""
|
||||
APIUtil.hasEntitlement(checkBankId, userId, role)
|
||||
}
|
||||
|
||||
if (hasRole) {
|
||||
IO.pure(Right(cc2))
|
||||
} else {
|
||||
IO.pure(Left(Response[IO](org.http4s.Status.Forbidden)))
|
||||
}
|
||||
case _ => IO.pure(Right(cc2))
|
||||
}
|
||||
|
||||
rolesResult.flatMap {
|
||||
case Left(errorResponse) => IO.pure(errorResponse)
|
||||
case Right(cc3) =>
|
||||
// Step 4: Account validation (if ACCOUNT_ID in path)
|
||||
val accountResult: IO[Either[Response[IO], (Option[BankAccount], SharedCallContext)]] =
|
||||
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID")) match {
|
||||
case (Some(bankIdStr), Some(accountIdStr)) =>
|
||||
IO.fromFuture(IO(
|
||||
NewStyle.function.getBankAccount(BankId(bankIdStr), AccountId(accountIdStr), Some(cc3))
|
||||
)).attempt.map {
|
||||
case Right((account, Some(updatedCC))) => Right((Some(account), updatedCC))
|
||||
case Right((account, None)) => Right((Some(account), cc3))
|
||||
case Left(_) => Left(Response[IO](org.http4s.Status.NotFound))
|
||||
}
|
||||
case _ => IO.pure(Right((None, cc3)))
|
||||
}
|
||||
|
||||
accountResult.flatMap {
|
||||
case Left(errorResponse) => IO.pure(errorResponse)
|
||||
case Right((accountOpt, cc4)) =>
|
||||
// Step 5: View validation (if VIEW_ID in path)
|
||||
val viewResult: IO[Either[Response[IO], (Option[View], SharedCallContext)]] =
|
||||
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("VIEW_ID")) match {
|
||||
case (Some(bankIdStr), Some(accountIdStr), Some(viewIdStr)) =>
|
||||
val bankIdAccountId = BankIdAccountId(BankId(bankIdStr), AccountId(accountIdStr))
|
||||
IO.fromFuture(IO(
|
||||
ViewNewStyle.checkViewAccessAndReturnView(
|
||||
ViewId(viewIdStr),
|
||||
bankIdAccountId,
|
||||
boxUser.toOption,
|
||||
Some(cc4)
|
||||
)
|
||||
)).attempt.map {
|
||||
case Right(view) => Right((Some(view), cc4))
|
||||
case Left(_) => Left(Response[IO](org.http4s.Status.Forbidden))
|
||||
}
|
||||
case _ => IO.pure(Right((None, cc4)))
|
||||
}
|
||||
|
||||
viewResult.flatMap {
|
||||
case Left(errorResponse) => IO.pure(errorResponse)
|
||||
case Right((viewOpt, cc5)) =>
|
||||
// Step 6: Counterparty validation (if COUNTERPARTY_ID in path)
|
||||
val counterpartyResult: IO[Either[Response[IO], (Option[CounterpartyTrait], SharedCallContext)]] =
|
||||
pathParams.get("COUNTERPARTY_ID") match {
|
||||
case Some(_) =>
|
||||
// For now, skip counterparty validation - can be added later
|
||||
IO.pure(Right((None, cc5)))
|
||||
case None => IO.pure(Right((None, cc5)))
|
||||
}
|
||||
|
||||
counterpartyResult.flatMap {
|
||||
case Left(errorResponse) => IO.pure(errorResponse)
|
||||
case Right((counterpartyOpt, finalCC)) =>
|
||||
// All validations passed - store validated context and invoke route
|
||||
val validatedContext = ValidatedContext(
|
||||
user = boxUser.toOption,
|
||||
bank = bankOpt,
|
||||
bankAccount = accountOpt,
|
||||
view = viewOpt,
|
||||
counterparty = counterpartyOpt,
|
||||
callContext = finalCC
|
||||
)
|
||||
|
||||
// Store validated objects in request attributes
|
||||
var updatedReq = req.withAttribute(Http4sVaultKeys.callContextKey, finalCC)
|
||||
boxUser.toOption.foreach { user =>
|
||||
updatedReq = updatedReq.withAttribute(Http4sVaultKeys.userKey, user)
|
||||
}
|
||||
bankOpt.foreach { bank =>
|
||||
updatedReq = updatedReq.withAttribute(Http4sVaultKeys.bankKey, bank)
|
||||
}
|
||||
accountOpt.foreach { account =>
|
||||
updatedReq = updatedReq.withAttribute(Http4sVaultKeys.bankAccountKey, account)
|
||||
}
|
||||
viewOpt.foreach { view =>
|
||||
updatedReq = updatedReq.withAttribute(Http4sVaultKeys.viewKey, view)
|
||||
}
|
||||
counterpartyOpt.foreach { counterparty =>
|
||||
updatedReq = updatedReq.withAttribute(Http4sVaultKeys.counterpartyKey, counterparty)
|
||||
}
|
||||
|
||||
// Invoke the original route
|
||||
routes.run(updatedReq).getOrElseF(
|
||||
IO.pure(Response[IO](org.http4s.Status.NotFound))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user