Merge remote-tracking branch 'upstream/develop' into develop

This commit is contained in:
simonredfern 2026-01-22 18:38:39 +01:00
commit 6ad3689fd3
11 changed files with 1160 additions and 116 deletions

3
.gitignore vendored
View File

@ -12,6 +12,7 @@
.zed
.cursor
.trae
.kiro
.classpath
.project
.cache
@ -44,4 +45,4 @@ coursier
metals.sbt
obp-http4s-runner/src/main/resources/git.properties
test-results
untracked_files/
untracked_files/

View File

@ -416,6 +416,69 @@ object DirectLogin extends RestHelper with MdcLoggable {
}
/**
* Validator that uses pre-extracted parameters from CallContext (for http4s support)
* This avoids dependency on S.request which is not available in http4s context
*/
def validatorFutureWithParams(requestType: String, httpMethod: String, parameters: Map[String, String]): Future[(Int, String, Map[String, String])] = {
def validAccessTokenFuture(tokenKey: String) = {
Tokens.tokens.vend.getTokenByKeyAndTypeFuture(tokenKey, TokenType.Access) map {
case Full(token) => token.isValid
case _ => false
}
}
var message = ""
var httpCode: Int = 500
val missingParams = missingDirectLoginParameters(parameters, requestType)
val validParams = validDirectLoginParameters(parameters)
val validF =
if (requestType == "protectedResource") {
validAccessTokenFuture(parameters.getOrElse("token", ""))
} else if (requestType == "authorizationToken" &&
APIUtil.getPropsAsBoolValue("direct_login_consumer_key_mandatory", true)) {
APIUtil.registeredApplicationFuture(parameters.getOrElse("consumer_key", ""))
} else {
Future { true }
}
for {
valid <- validF
} yield {
if (parameters.get("error").isDefined) {
message = parameters.get("error").getOrElse("")
httpCode = 400
}
else if (missingParams.nonEmpty) {
message = ErrorMessages.DirectLoginMissingParameters + missingParams.mkString(", ")
httpCode = 400
}
else if (SILENCE_IS_GOLDEN != validParams.mkString("")) {
message = validParams.mkString("")
httpCode = 400
}
else if (requestType == "protectedResource" && !valid) {
message = ErrorMessages.DirectLoginInvalidToken + parameters.getOrElse("token", "")
httpCode = 401
}
else if (requestType == "authorizationToken" &&
APIUtil.getPropsAsBoolValue("direct_login_consumer_key_mandatory", true) &&
!valid) {
logger.error("application: " + parameters.getOrElse("consumer_key", "") + " not found")
message = ErrorMessages.InvalidConsumerKey
httpCode = 401
}
else
httpCode = 200
if (message.nonEmpty)
logger.error("error message : " + message)
(httpCode, message, parameters)
}
}
private def generateTokenAndSecret(claims: JWTClaimsSet): (String, String) =
{
// generate random string
@ -473,12 +536,20 @@ object DirectLogin extends RestHelper with MdcLoggable {
}
def getUserFromDirectLoginHeaderFuture(sc: CallContext) : Future[(Box[User], Option[CallContext])] = {
val httpMethod = S.request match {
val httpMethod = if (sc.verb.nonEmpty) sc.verb else S.request match {
case Full(r) => r.request.method
case _ => "GET"
}
// Prefer directLoginParams from CallContext (http4s), fall back to S.request (Lift)
val directLoginParamsFromCC = sc.directLoginParams
for {
(httpCode, message, directLoginParameters) <- validatorFuture("protectedResource", httpMethod)
(httpCode, message, directLoginParameters) <- if (directLoginParamsFromCC.nonEmpty && directLoginParamsFromCC.contains("token")) {
// Use params from CallContext (http4s path)
validatorFutureWithParams("protectedResource", httpMethod, directLoginParamsFromCC)
} else {
// Fall back to S.request (Lift path), e.g. we still use Lift to generate the token and secret, so we need to maintain backward compatibility here.
validatorFuture("protectedResource", httpMethod)
}
_ <- Future { if (httpCode == 400 || httpCode == 401) Empty else Full("ok") } map { x => fullBoxOrException(x ?~! message) }
consumer <- OAuthHandshake.getConsumerFromTokenFuture(200, (if (directLoginParameters.isDefinedAt("token")) directLoginParameters.get("token") else Empty))
user <- OAuthHandshake.getUserFromTokenFuture(200, (if (directLoginParameters.isDefinedAt("token")) directLoginParameters.get("token") else Empty))

View File

@ -334,6 +334,17 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
commit
}
// API info props helpers (keep values centralized)
lazy val hostedByOrganisation: String = getPropsValue("hosted_by.organisation", "TESOBE")
lazy val hostedByEmail: String = getPropsValue("hosted_by.email", "contact@tesobe.com")
lazy val hostedByPhone: String = getPropsValue("hosted_by.phone", "+49 (0)30 8145 3994")
lazy val organisationWebsite: String = getPropsValue("organisation_website", "https://www.tesobe.com")
lazy val hostedAtOrganisation: String = getPropsValue("hosted_at.organisation", "")
lazy val hostedAtOrganisationWebsite: String = getPropsValue("hosted_at.organisation_website", "")
lazy val energySourceOrganisation: String = getPropsValue("energy_source.organisation", "")
lazy val energySourceOrganisationWebsite: String = getPropsValue("energy_source.organisation_website", "")
lazy val resourceDocsRequiresRole: Boolean = getPropsAsBoolValue("resource_docs_requires_role", false)
/**
* Caching of unchanged resources
@ -3039,18 +3050,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)

View File

@ -55,7 +55,12 @@ case class CallContext(
xRateLimitRemaining : Long = -1,
xRateLimitReset : Long = -1,
paginationOffset : Option[String] = None,
paginationLimit : Option[String] = None
paginationLimit : Option[String] = None,
// Validated entities from ResourceDoc middleware (http4s)
bank: Option[Bank] = None,
bankAccount: Option[BankAccount] = None,
view: Option[View] = None,
counterparty: Option[CounterpartyTrait] = None
) extends MdcLoggable {
override def toString: String = SecureLogging.maskSensitive(
s"${this.getClass.getSimpleName}(${this.productIterator.mkString(", ")})"

View File

@ -0,0 +1,116 @@
package code.api.util.http4s
import cats.effect._
import code.api.APIFailureNewStyle
import code.api.util.ErrorMessages._
import code.api.util.CallContext
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].
*
* Handles:
* - APIFailureNewStyle (structured errors with code and message)
* - Box Failure (Lift framework errors)
* - Unknown exceptions
*
* All responses include:
* - JSON body with code and message
* - Correlation-Id header for request tracing
* - Appropriate HTTP status code
*/
object ErrorResponseConverter {
import net.liftweb.json.Formats
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 using Lift JSON.
*/
private def toJsonString(error: OBPErrorResponse): String = {
val json = ("code" -> error.code) ~ ("message" -> error.message)
compactRender(json)
}
/**
* Convert any error to http4s Response[IO].
*/
def toHttp4sResponse(error: Throwable, callContext: CallContext): IO[Response[IO]] = {
error match {
case e: APIFailureNewStyle => apiFailureToResponse(e, callContext)
case _ => unknownErrorToResponse(error, callContext)
}
}
/**
* Convert APIFailureNewStyle to http4s Response.
* Uses failCode as HTTP status and failMsg as error message.
*/
def apiFailureToResponse(failure: APIFailureNewStyle, callContext: CallContext): IO[Response[IO]] = {
val errorJson = OBPErrorResponse(failure.failCode, failure.failMsg)
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 Lift Box Failure to http4s Response.
* Returns 400 Bad Request with failure message.
*/
def boxFailureToResponse(failure: LiftFailure, callContext: CallContext): IO[Response[IO]] = {
val errorJson = OBPErrorResponse(400, failure.msg)
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.
* Returns 500 Internal Server Error.
*/
def unknownErrorToResponse(e: Throwable, callContext: CallContext): 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: CallContext): 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))
)
}
}

View File

@ -0,0 +1,422 @@
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
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.dsl.io._
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.concurrent.Future
import scala.language.higherKinds
/**
* Http4s support utilities for OBP API.
*
* This file contains three main components:
*
* 1. Http4sRequestAttributes: Request attribute management and endpoint helpers
* - Stores CallContext in http4s request Vault
* - Provides helper methods to simplify endpoint implementations
* - Validated entities are stored in CallContext fields
*
* 2. Http4sCallContextBuilder: Builds CallContext from http4s Request[IO]
* - Extracts headers, auth params, and request metadata
* - Supports DirectLogin, OAuth, and Gateway authentication
*
* 3. ResourceDocMatcher: Matches requests to ResourceDoc entries
* - Finds ResourceDoc by HTTP verb and URL pattern
* - Extracts path parameters (BANK_ID, ACCOUNT_ID, etc.)
* - Attaches ResourceDoc to CallContext for metrics/rate limiting
*/
/**
* Request attributes and helper methods for http4s endpoints.
*
* CallContext is stored in request attributes using http4s Vault (type-safe key-value store).
* Validated entities (user, bank, bankAccount, view, counterparty) are stored within CallContext.
*/
object Http4sRequestAttributes {
/**
* Vault key for storing CallContext in http4s request attributes.
* CallContext contains request data and validated entities (user, bank, account, view, counterparty).
*/
val callContextKey: Key[CallContext] =
Key.newKey[IO, CallContext].unsafeRunSync()(cats.effect.unsafe.IORuntime.global)
/**
* Implicit class that adds .callContext accessor to Request[IO].
*
* Usage:
* {{{
* import Http4sRequestAttributes.RequestOps
*
* case req @ GET -> Root / "banks" =>
* implicit val cc: CallContext = req.callContext
* // Use cc for business logic
* }}}
*/
implicit class RequestOps(val req: Request[IO]) extends AnyVal {
/**
* Extract CallContext from request attributes.
* Throws RuntimeException if not found (should never happen with ResourceDocMiddleware).
*/
def callContext: CallContext = {
req.attributes.lookup(callContextKey).getOrElse(
throw new RuntimeException("CallContext not found in request attributes")
)
}
}
/**
* Helper methods to eliminate boilerplate in endpoint implementations.
*
* These methods handle:
* - CallContext extraction from request
* - User/Bank extraction from CallContext
* - Future execution with IO.fromFuture
* - JSON serialization with Lift JSON
* - Ok response creation
*/
object EndpointHelpers {
import net.liftweb.json.{Extraction, Formats}
import net.liftweb.json.JsonAST.prettyRender
/**
* Execute Future-based business logic and return JSON response.
*
* Handles: Future execution, JSON conversion, Ok response.
*
* @param req http4s request
* @param f Business logic: CallContext => Future[A]
* @return IO[Response[IO]] with JSON body
*/
def executeAndRespond[A](req: Request[IO])(f: CallContext => Future[A])(implicit formats: Formats): IO[Response[IO]] = {
implicit val cc: CallContext = req.callContext
for {
result <- IO.fromFuture(IO(f(cc)))
jsonString = prettyRender(Extraction.decompose(result))
response <- Ok(jsonString)
} yield response
}
/**
* Execute business logic requiring validated User.
*
* Extracts User from CallContext, executes logic, returns JSON response.
*
* @param req http4s request
* @param f Business logic: (User, CallContext) => Future[A]
* @return IO[Response[IO]] with JSON body
*/
def withUser[A](req: Request[IO])(f: (User, CallContext) => Future[A])(implicit formats: Formats): IO[Response[IO]] = {
implicit val cc: CallContext = req.callContext
for {
user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext"))
result <- IO.fromFuture(IO(f(user, cc)))
jsonString = prettyRender(Extraction.decompose(result))
response <- Ok(jsonString)
} yield response
}
/**
* Execute business logic requiring validated Bank.
*
* Extracts Bank from CallContext, executes logic, returns JSON response.
*
* @param req http4s request
* @param f Business logic: (Bank, CallContext) => Future[A]
* @return IO[Response[IO]] with JSON body
*/
def withBank[A](req: Request[IO])(f: (Bank, CallContext) => Future[A])(implicit formats: Formats): IO[Response[IO]] = {
implicit val cc: CallContext = req.callContext
for {
bank <- IO.fromOption(cc.bank)(new RuntimeException("Bank not found in CallContext"))
result <- IO.fromFuture(IO(f(bank, cc)))
jsonString = prettyRender(Extraction.decompose(result))
response <- Ok(jsonString)
} yield response
}
/**
* Execute business logic requiring both User and Bank.
*
* Extracts both from CallContext, executes logic, returns JSON response.
*
* @param req http4s request
* @param f Business logic: (User, Bank, CallContext) => Future[A]
* @return IO[Response[IO]] with JSON body
*/
def withUserAndBank[A](req: Request[IO])(f: (User, Bank, CallContext) => Future[A])(implicit formats: Formats): IO[Response[IO]] = {
implicit val cc: CallContext = req.callContext
for {
user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext"))
bank <- IO.fromOption(cc.bank)(new RuntimeException("Bank not found in CallContext"))
result <- IO.fromFuture(IO(f(user, bank, cc)))
jsonString = prettyRender(Extraction.decompose(result))
response <- Ok(jsonString)
} yield response
}
}
}
/**
* 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[CallContext] with all request data populated
*/
def fromRequest(request: Request[IO], apiVersion: String): IO[CallContext] = {
for {
body <- request.bodyText.compile.string.map(s => if (s.isEmpty) None else Some(s))
} yield CallContext(
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
* Supports two formats:
* 1. New format (2021): DirectLogin: token=xxx
* 2. Old format (deprecated): Authorization: DirectLogin token=xxx
*/
private def extractDirectLoginParams(request: Request[IO]): Map[String, String] = {
// Try new format first: DirectLogin header
request.headers.get(CIString("DirectLogin"))
.map(h => parseDirectLoginHeader(h.head.value))
.getOrElse {
// Fall back to old format: Authorization: DirectLogin token=xxx
request.headers.get(CIString("Authorization"))
.filter(_.head.value.contains("DirectLogin"))
.map(h => parseDirectLoginHeader(h.head.value))
.getOrElse(Map.empty)
}
}
/**
* Parse DirectLogin header value into parameter map
* Matches Lift's parsing logic in directlogin.scala getAllParameters
* Supports formats:
* - DirectLogin token="xxx"
* - DirectLogin token=xxx
* - token="xxx", username="yyy"
*/
private def parseDirectLoginHeader(headerValue: String): Map[String, String] = {
val directLoginPossibleParameters = List("consumer_key", "token", "username", "password")
// Strip "DirectLogin" prefix and split by comma, then trim each part (matches Lift logic)
val cleanedParameterList = headerValue.stripPrefix("DirectLogin").split(",").map(_.trim).toList
cleanedParameterList.flatMap { input =>
if (input.contains("=")) {
val split = input.split("=", 2)
val paramName = split(0).trim
// Remove surrounding quotes if present
val paramValue = split(1).replaceAll("^\"|\"$", "").trim
if (directLoginPossibleParameters.contains(paramName) && paramValue.nonEmpty)
Some(paramName -> paramValue)
else
None
} else {
None
}
}.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 {
// API prefix pattern: /obp/vX.X.X
private val apiPrefixPattern = """^/obp/v\d+\.\d+\.\d+""".r
/**
* 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
// Strip the API prefix (/obp/vX.X.X) from the path for matching
val strippedPath = apiPrefixPattern.replaceFirstIn(pathString, "")
resourceDocs.find { doc =>
doc.requestVerb.equalsIgnoreCase(verb) && matchesUrlTemplate(strippedPath, 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
// Strip the API prefix (/obp/vX.X.X) from the path for matching
val strippedPath = apiPrefixPattern.replaceFirstIn(pathString, "")
val pathSegments = strippedPath.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: CallContext,
resourceDoc: ResourceDoc
): CallContext = {
callContext.copy(
resourceDocument = Some(resourceDoc),
operationId = Some(resourceDoc.operationId)
)
}
}

View File

@ -0,0 +1,293 @@
package code.api.util.http4s
import cats.data.{Kleisli, OptionT}
import cats.effect._
import code.api.APIFailureNewStyle
import code.api.util.APIUtil.ResourceDoc
import code.api.util.ErrorMessages._
import code.api.util.{APIUtil, CallContext, NewStyle}
import code.api.util.newstyle.ViewNewStyle
import code.util.Helper.MdcLoggable
import com.openbankproject.commons.model._
import net.liftweb.common.{Box, Empty, Full, Failure => LiftFailure}
import org.http4s._
import org.http4s.headers.`Content-Type`
import scala.collection.mutable.ArrayBuffer
import scala.language.higherKinds
/**
* ResourceDoc-driven validation middleware for http4s.
*
* This middleware wraps http4s routes with automatic validation based on ResourceDoc metadata.
* Validation is performed in a specific order to ensure security and proper error responses.
*
* VALIDATION ORDER:
* 1. Authentication - Check if user is authenticated (if required by ResourceDoc)
* 2. Authorization - Verify user has required roles/entitlements
* 3. Bank validation - Validate BANK_ID path parameter (if present)
* 4. Account validation - Validate ACCOUNT_ID path parameter (if present)
* 5. View validation - Validate VIEW_ID and check user access (if present)
* 6. Counterparty validation - Validate COUNTERPARTY_ID (if present)
*
* Validated entities are stored in CallContext fields for use in endpoint handlers.
*/
object ResourceDocMiddleware extends MdcLoggable{
type HttpF[A] = OptionT[IO, A]
type Middleware[F[_]] = HttpRoutes[F] => HttpRoutes[F]
private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json)
/**
* Check if ResourceDoc requires authentication.
*
* Authentication is required if:
* - ResourceDoc errorResponseBodies contains $AuthenticatedUserIsRequired
* - ResourceDoc has roles (roles always require authenticated user)
* - Special case: resource-docs endpoint checks resource_docs_requires_role property
*/
private def needsAuthentication(resourceDoc: ResourceDoc): Boolean = {
if (resourceDoc.partialFunctionName == "getResourceDocsObpV700") {
APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false)
} else {
resourceDoc.errorResponseBodies.contains($AuthenticatedUserIsRequired) || resourceDoc.roles.exists(_.nonEmpty)
}
}
/**
* Create middleware that applies ResourceDoc-driven validation.
*
* @param resourceDocs Collection of ResourceDoc entries for matching
* @return Middleware that wraps HttpRoutes with validation
*/
def apply(resourceDocs: ArrayBuffer[ResourceDoc]): Middleware[IO] = { routes =>
Kleisli[HttpF, Request[IO], Response[IO]] { req =>
OptionT(validateAndRoute(req, routes, resourceDocs).map(Option(_)))
}
}
/**
* Validate request and route to handler if validation passes.
*
* Steps:
* 1. Build CallContext from request
* 2. Find matching ResourceDoc
* 3. Run validation chain
* 4. Route to handler with enriched CallContext
*/
private def validateAndRoute(
req: Request[IO],
routes: HttpRoutes[IO],
resourceDocs: ArrayBuffer[ResourceDoc]
): IO[Response[IO]] = {
for {
cc <- Http4sCallContextBuilder.fromRequest(req, "v7.0.0")
resourceDocOpt = ResourceDocMatcher.findResourceDoc(req.method.name, req.uri.path, resourceDocs)
response <- resourceDocOpt match {
case Some(resourceDoc) =>
val ccWithDoc = ResourceDocMatcher.attachToCallContext(cc, resourceDoc)
val pathParams = ResourceDocMatcher.extractPathParams(req.uri.path, resourceDoc)
runValidationChainForRoutes(req, resourceDoc, ccWithDoc, pathParams, routes)
.map(ensureJsonContentType)
case None =>
routes.run(req).getOrElseF(IO.pure(Response[IO](org.http4s.Status.NotFound)))
}
} yield response
}
/**
* Ensure response has JSON content type.
*/
private def ensureJsonContentType(response: Response[IO]): Response[IO] = {
response.contentType match {
case Some(contentType) if contentType.mediaType == MediaType.application.json => response
case _ => response.withContentType(jsonContentType)
}
}
/**
* Run validation chain for HttpRoutes and return Response.
*
* This method performs all validation steps in order:
* 1. Authentication (if required)
* 2. Role authorization (if roles specified)
* 3. Bank validation (if BANK_ID in path)
* 4. Account validation (if ACCOUNT_ID in path)
* 5. View validation (if VIEW_ID in path)
* 6. Counterparty validation (if COUNTERPARTY_ID in path)
*
* On success: Enriches CallContext with validated entities and routes to handler
* On failure: Returns error response immediately
*/
private def runValidationChainForRoutes(
req: Request[IO],
resourceDoc: ResourceDoc,
cc: CallContext,
pathParams: Map[String, String],
routes: HttpRoutes[IO]
): IO[Response[IO]] = {
val needsAuth = needsAuthentication(resourceDoc)
logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth")
// Step 1: Authentication
val authResult: IO[Either[Response[IO], (Box[User], CallContext)]] =
if (needsAuth) {
IO.fromFuture(IO(APIUtil.authenticatedAccess(cc))).attempt.flatMap {
case Right((boxUser, optCC)) =>
val updatedCC = optCC.getOrElse(cc)
boxUser match {
case Full(user) =>
IO.pure(Right((boxUser, updatedCC)))
case Empty =>
ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, updatedCC).map(Left(_))
case LiftFailure(msg, _, _) =>
ErrorResponseConverter.createErrorResponse(401, msg, updatedCC).map(Left(_))
}
case Left(e: APIFailureNewStyle) =>
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc).map(Left(_))
case Left(e) =>
val (code, msg) = try {
import net.liftweb.json._
implicit val formats = net.liftweb.json.DefaultFormats
val json = parse(e.getMessage)
val failCode = (json \ "failCode").extractOpt[Int].getOrElse(401)
val failMsg = (json \ "failMsg").extractOpt[String].getOrElse($AuthenticatedUserIsRequired)
(failCode, failMsg)
} catch {
case _: Exception => (401, $AuthenticatedUserIsRequired)
}
ErrorResponseConverter.createErrorResponse(code, msg, cc).map(Left(_))
}
} else {
IO.fromFuture(IO(APIUtil.anonymousAccess(cc))).attempt.flatMap {
case Right((boxUser, Some(updatedCC))) =>
IO.pure(Right((boxUser, updatedCC)))
case Right((boxUser, None)) =>
IO.pure(Right((boxUser, cc)))
case Left(e) =>
// For anonymous endpoints, continue with Empty user even if auth fails
IO.pure(Right((Empty, cc)))
}
}
authResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right((boxUser, cc1)) =>
// Step 2: Role authorization
val rolesResult: IO[Either[Response[IO], CallContext]] =
resourceDoc.roles match {
case Some(roles) if roles.nonEmpty =>
boxUser match {
case Full(user) =>
val userId = user.userId
val bankId = pathParams.get("BANK_ID").getOrElse("")
val hasRole = roles.exists { role =>
val checkBankId = if (role.requiresBankId) bankId else ""
APIUtil.hasEntitlement(checkBankId, userId, role)
}
if (hasRole) IO.pure(Right(cc1))
else ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(", "), cc1).map(Left(_))
case _ =>
ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, cc1).map(Left(_))
}
case _ => IO.pure(Right(cc1))
}
rolesResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right(cc2) =>
// Step 3: Bank validation
val bankResult: IO[Either[Response[IO], (Option[Bank], CallContext)]] =
pathParams.get("BANK_ID") match {
case Some(bankIdStr) =>
IO.fromFuture(IO(NewStyle.function.getBank(BankId(bankIdStr), Some(cc2)))).attempt.flatMap {
case Right((bank, Some(updatedCC))) =>
IO.pure(Right((Some(bank), updatedCC)))
case Right((bank, None)) =>
IO.pure(Right((Some(bank), cc2)))
case Left(e: APIFailureNewStyle) =>
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc2).map(Left(_))
case Left(e) =>
ErrorResponseConverter.createErrorResponse(404, BankNotFound + ": " + bankIdStr, cc2).map(Left(_))
}
case None => IO.pure(Right((None, cc2)))
}
bankResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right((bankOpt, cc3)) =>
// Step 4: Account validation (if ACCOUNT_ID in path)
val accountResult: IO[Either[Response[IO], (Option[BankAccount], CallContext)]] =
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID")) match {
case (Some(bankIdStr), Some(accountIdStr)) =>
IO.fromFuture(IO(NewStyle.function.getBankAccount(BankId(bankIdStr), AccountId(accountIdStr), Some(cc3)))).attempt.flatMap {
case Right((account, Some(updatedCC))) => IO.pure(Right((Some(account), updatedCC)))
case Right((account, None)) => IO.pure(Right((Some(account), cc3)))
case Left(e: APIFailureNewStyle) =>
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc3).map(Left(_))
case Left(e) =>
ErrorResponseConverter.createErrorResponse(404, BankAccountNotFound + s": bankId=$bankIdStr, accountId=$accountIdStr", cc3).map(Left(_))
}
case _ => IO.pure(Right((None, cc3)))
}
accountResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right((accountOpt, cc4)) =>
// Step 5: View validation (if VIEW_ID in path)
val viewResult: IO[Either[Response[IO], (Option[View], CallContext)]] =
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("VIEW_ID")) match {
case (Some(bankIdStr), Some(accountIdStr), Some(viewIdStr)) =>
val bankIdAccountId = BankIdAccountId(BankId(bankIdStr), AccountId(accountIdStr))
IO.fromFuture(IO(ViewNewStyle.checkViewAccessAndReturnView(ViewId(viewIdStr), bankIdAccountId, boxUser.toOption, Some(cc4)))).attempt.flatMap {
case Right(view) => IO.pure(Right((Some(view), cc4)))
case Left(e: APIFailureNewStyle) =>
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc4).map(Left(_))
case Left(e) =>
ErrorResponseConverter.createErrorResponse(403, UserNoPermissionAccessView + s": viewId=$viewIdStr", cc4).map(Left(_))
}
case _ => IO.pure(Right((None, cc4)))
}
viewResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right((viewOpt, cc5)) =>
// Step 6: Counterparty validation (if COUNTERPARTY_ID in path)
val counterpartyResult: IO[Either[Response[IO], (Option[CounterpartyTrait], CallContext)]] =
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("COUNTERPARTY_ID")) match {
case (Some(bankIdStr), Some(accountIdStr), Some(counterpartyIdStr)) =>
IO.fromFuture(IO(NewStyle.function.getCounterpartyTrait(BankId(bankIdStr), AccountId(accountIdStr), counterpartyIdStr, Some(cc5)))).attempt.flatMap {
case Right((counterparty, Some(updatedCC))) => IO.pure(Right((Some(counterparty), updatedCC)))
case Right((counterparty, None)) => IO.pure(Right((Some(counterparty), cc5)))
case Left(e: APIFailureNewStyle) =>
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc5).map(Left(_))
case Left(e) =>
ErrorResponseConverter.createErrorResponse(404, CounterpartyNotFound + s": counterpartyId=$counterpartyIdStr", cc5).map(Left(_))
}
case _ => IO.pure(Right((None, cc5)))
}
counterpartyResult.flatMap {
case Left(errorResponse) => IO.pure(errorResponse)
case Right((counterpartyOpt, finalCC)) =>
// All validations passed - update CallContext with validated entities
val enrichedCC = finalCC.copy(
bank = bankOpt,
bankAccount = accountOpt,
view = viewOpt,
counterparty = counterpartyOpt
)
// Store enriched CallContext in request attributes
val updatedReq = req.withAttribute(Http4sRequestAttributes.callContextKey, enrichedCC)
routes.run(updatedReq).getOrElseF(IO.pure(Response[IO](org.http4s.Status.NotFound)))
}
}
}
}
}
}
}
}

View File

@ -1095,22 +1095,22 @@ case class JsonCodeTemplateJson(
object JSONFactory400 {
def getApiInfoJSON(apiVersion : ApiVersion, apiVersionStatus : String) = {
val organisation = APIUtil.getPropsValue("hosted_by.organisation", "TESOBE")
val email = APIUtil.getPropsValue("hosted_by.email", "contact@tesobe.com")
val phone = APIUtil.getPropsValue("hosted_by.phone", "+49 (0)30 8145 3994")
val organisationWebsite = APIUtil.getPropsValue("organisation_website", "https://www.tesobe.com")
val organisation = APIUtil.hostedByOrganisation
val email = APIUtil.hostedByEmail
val phone = APIUtil.hostedByPhone
val organisationWebsite = APIUtil.organisationWebsite
val hostedBy = new HostedBy400(organisation, email, phone, organisationWebsite)
val organisationHostedAt = APIUtil.getPropsValue("hosted_at.organisation", "")
val organisationWebsiteHostedAt = APIUtil.getPropsValue("hosted_at.organisation_website", "")
val organisationHostedAt = APIUtil.hostedAtOrganisation
val organisationWebsiteHostedAt = APIUtil.hostedAtOrganisationWebsite
val hostedAt = new HostedAt400(organisationHostedAt, organisationWebsiteHostedAt)
val organisationEnergySource = APIUtil.getPropsValue("energy_source.organisation", "")
val organisationWebsiteEnergySource = APIUtil.getPropsValue("energy_source.organisation_website", "")
val organisationEnergySource = APIUtil.energySourceOrganisation
val organisationWebsiteEnergySource = APIUtil.energySourceOrganisationWebsite
val energySource = new EnergySource400(organisationEnergySource, organisationWebsiteEnergySource)
val connector = code.api.Constant.CONNECTOR.openOrThrowException(s"$MandatoryPropertyIsNotSet. The missing prop is `connector` ")
val resourceDocsRequiresRole = APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false)
val resourceDocsRequiresRole = APIUtil.resourceDocsRequiresRole
APIInfoJson400(
apiVersion.vDottedApiVersion,

View File

@ -1049,22 +1049,22 @@ object JSONFactory510 extends CustomJsonFormats with MdcLoggable {
}
def getApiInfoJSON(apiVersion : ApiVersion, apiVersionStatus: String) = {
val organisation = APIUtil.getPropsValue("hosted_by.organisation", "TESOBE")
val email = APIUtil.getPropsValue("hosted_by.email", "contact@tesobe.com")
val phone = APIUtil.getPropsValue("hosted_by.phone", "+49 (0)30 8145 3994")
val organisationWebsite = APIUtil.getPropsValue("organisation_website", "https://www.tesobe.com")
val organisation = APIUtil.hostedByOrganisation
val email = APIUtil.hostedByEmail
val phone = APIUtil.hostedByPhone
val organisationWebsite = APIUtil.organisationWebsite
val hostedBy = new HostedBy400(organisation, email, phone, organisationWebsite)
val organisationHostedAt = APIUtil.getPropsValue("hosted_at.organisation", "")
val organisationWebsiteHostedAt = APIUtil.getPropsValue("hosted_at.organisation_website", "")
val organisationHostedAt = APIUtil.hostedAtOrganisation
val organisationWebsiteHostedAt = APIUtil.hostedAtOrganisationWebsite
val hostedAt = HostedAt400(organisationHostedAt, organisationWebsiteHostedAt)
val organisationEnergySource = APIUtil.getPropsValue("energy_source.organisation", "")
val organisationWebsiteEnergySource = APIUtil.getPropsValue("energy_source.organisation_website", "")
val organisationEnergySource = APIUtil.energySourceOrganisation
val organisationWebsiteEnergySource = APIUtil.energySourceOrganisationWebsite
val energySource = EnergySource400(organisationEnergySource, organisationWebsiteEnergySource)
val connector = code.api.Constant.CONNECTOR.openOrThrowException(s"$MandatoryPropertyIsNotSet. The missing prop is `connector` ")
val resourceDocsRequiresRole = APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false)
val resourceDocsRequiresRole = APIUtil.resourceDocsRequiresRole
APIInfoJsonV510(
version = apiVersion.vDottedApiVersion,

View File

@ -6,9 +6,13 @@ import code.api.Constant._
import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._
import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocsAPIMethodsUtil}
import code.api.util.APIUtil.{EmptyBody, _}
import code.api.util.ApiRole.{canGetCardsForBank, canReadResourceDoc}
import code.api.util.ApiTag._
import code.api.util.ErrorMessages._
import code.api.util.{ApiVersionUtils, CustomJsonFormats, NewStyle}
import code.api.util.http4s.{Http4sRequestAttributes, ResourceDocMiddleware}
import code.api.util.http4s.Http4sRequestAttributes.{RequestOps, EndpointHelpers}
import code.api.util.{ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle}
import code.api.v1_3_0.JSONFactory1_3_0
import code.api.v1_4_0.JSONFactory1_4_0
import code.api.v4_0_0.JSONFactory400
import com.github.dwickern.macros.NameOf.nameOf
@ -18,7 +22,6 @@ import net.liftweb.json.JsonAST.prettyRender
import net.liftweb.json.{Extraction, Formats}
import org.http4s._
import org.http4s.dsl.io._
import org.typelevel.vault.Key
import scala.collection.mutable.ArrayBuffer
import scala.concurrent.Future
@ -35,27 +38,14 @@ 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
val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString
// ResourceDoc with AuthenticatedUserIsRequired in errorResponseBodies indicates auth is required
// ResourceDocMiddleware will automatically handle authentication based on this metadata
// No explicit auth code needed in the endpoint handler - just like Lift's wrappedWithAuthCheck
resourceDocs += ResourceDoc(
null,
implementedInApiVersion,
@ -68,27 +58,25 @@ object Http4s700 {
|* API version
|* Hosted by information
|* Git Commit
|${userAuthenticationMessage(false)}""",
""",
EmptyBody,
apiInfoJSON,
List(UnknownError, "no connector set"),
apiInfoJSON,
List(
UnknownError
),
apiTagApi :: Nil,
http4sPartialFunction = Some(root)
)
// Route: GET /obp/v7.0.0/root
// Authentication is handled automatically by ResourceDocMiddleware based on AuthenticatedUserIsRequired in ResourceDoc
// The endpoint code only contains business logic - validated User is available from request attributes
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}.")
)
}
)))
val responseJson = convertAnyToJsonString(
JSONFactory700.getApiInfoJSON(implementedInApiVersion, versionStatus)
)
Ok(responseJson)
}
resourceDocs += ResourceDoc(
@ -104,11 +92,12 @@ object Http4s700 {
|* ID used as parameter in URLs
|* Short and full name of bank
|* Logo URL
|* Website
|${userAuthenticationMessage(false)}""",
|* Website""",
EmptyBody,
banksJSON,
List(UnknownError),
List(
UnknownError
),
apiTagBank :: Nil,
http4sPartialFunction = Some(getBanks)
)
@ -116,42 +105,160 @@ object Http4s700 {
// Route: GET /obp/v7.0.0/banks
val getBanks: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "banks" =>
import com.openbankproject.commons.ExecutionContext.Implicits.global
Ok(IO.fromFuture(IO(
EndpointHelpers.executeAndRespond(req) { implicit cc =>
for {
(banks, callContext) <- NewStyle.function.getBanks(None)
} yield {
convertAnyToJsonString(JSONFactory400.createBanksJson(banks))
}
)))
(banks, callContext) <- NewStyle.function.getBanks(Some(cc))
} yield JSONFactory400.createBanksJson(banks)
}
}
resourceDocs += ResourceDoc(
null,
implementedInApiVersion,
nameOf(getCards),
"GET",
"/cards",
"Get cards for the current user",
"Returns data about all the physical cards a user has been issued. These could be debit cards, credit cards, etc.",
EmptyBody,
physicalCardsJSON,
List(AuthenticatedUserIsRequired, UnknownError),
apiTagCard :: Nil,
http4sPartialFunction = Some(getCards)
)
// Route: GET /obp/v7.0.0/cards
// Authentication handled by ResourceDocMiddleware based on AuthenticatedUserIsRequired
val getCards: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "cards" =>
EndpointHelpers.withUser(req) { (user, cc) =>
for {
(cards, callContext) <- NewStyle.function.getPhysicalCardsForUser(user, Some(cc))
} yield JSONFactory1_3_0.createPhysicalCardsJSON(cards, user)
}
}
resourceDocs += ResourceDoc(
null,
implementedInApiVersion,
nameOf(getCardsForBank),
"GET",
"/banks/BANK_ID/cards",
"Get cards for the specified bank",
"",
EmptyBody,
physicalCardsJSON,
List(AuthenticatedUserIsRequired, BankNotFound, UnknownError),
apiTagCard :: Nil,
Some(List(canGetCardsForBank)),
http4sPartialFunction = Some(getCardsForBank)
)
// Route: GET /obp/v7.0.0/banks/BANK_ID/cards
// Authentication and bank validation handled by ResourceDocMiddleware
val getCardsForBank: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "banks" / bankId / "cards" =>
EndpointHelpers.withUserAndBank(req) { (user, bank, cc) =>
for {
httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString)
(obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(httpParams, Some(cc))
(cards, callContext) <- NewStyle.function.getPhysicalCardsForBank(bank, user, obpQueryParams, callContext)
} yield JSONFactory1_3_0.createPhysicalCardsJSON(cards, user)
}
}
resourceDocs += ResourceDoc(
null,
implementedInApiVersion,
nameOf(getResourceDocsObpV700),
"GET",
"/resource-docs/API_VERSION/obp",
"Get Resource Docs",
s"""Get documentation about the RESTful resources on this server including example body payloads.
|
|* API_VERSION: The version of the API for which you want documentation
|
|Returns JSON containing information about the endpoints including:
|* Method (GET, POST, etc.)
|* URL path
|* Summary and description
|* Example request and response bodies
|* Required roles and permissions
|
|Optional query parameters:
|* tags - filter by API tags
|* functions - filter by function names
|* locale - specify language for descriptions
|* content - filter by content type""",
EmptyBody,
EmptyBody,
List(
UnknownError
),
List(apiTagDocumentation, apiTagApi),
http4sPartialFunction = Some(getResourceDocsObpV700)
)
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)))
implicit val cc: CallContext = req.callContext
for {
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)
})
response <- Ok(result)
} yield response
}
// 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(getCards(req))
.orElse(getCardsForBank(req))
.orElse(getResourceDocsObpV700(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
// Authentication is automatic based on $AuthenticatedUserIsRequired in ResourceDoc errorResponseBodies
// This matches Lift's wrappedWithAuthCheck behavior
val wrappedRoutesV700Services: HttpRoutes[IO] = Implementations7_0_0.allRoutesWithMiddleware
}

View File

@ -6,20 +6,9 @@ import code.api.util.ErrorMessages.MandatoryPropertyIsNotSet
import code.api.v4_0_0.{EnergySource400, HostedAt400, HostedBy400}
import code.util.Helper.MdcLoggable
import com.openbankproject.commons.util.ApiVersion
import net.liftweb.util.Props
object JSONFactory700 extends MdcLoggable {
// Get git commit from build info
lazy val gitCommit: String = {
val commit = try {
Props.get("git.commit.id", "unknown")
} catch {
case _: Throwable => "unknown"
}
commit
}
case class APIInfoJsonV700(
version: String,
version_status: String,
@ -31,32 +20,31 @@ object JSONFactory700 extends MdcLoggable {
hosted_by: HostedBy400,
hosted_at: HostedAt400,
energy_source: EnergySource400,
resource_docs_requires_role: Boolean,
message: String
resource_docs_requires_role: Boolean
)
def getApiInfoJSON(apiVersion: ApiVersion, message: String): APIInfoJsonV700 = {
val organisation = APIUtil.getPropsValue("hosted_by.organisation", "TESOBE")
val email = APIUtil.getPropsValue("hosted_by.email", "contact@tesobe.com")
val phone = APIUtil.getPropsValue("hosted_by.phone", "+49 (0)30 8145 3994")
val organisationWebsite = APIUtil.getPropsValue("organisation_website", "https://www.tesobe.com")
def getApiInfoJSON(apiVersion: ApiVersion, apiVersionStatus: String): APIInfoJsonV700 = {
val organisation = APIUtil.hostedByOrganisation
val email = APIUtil.hostedByEmail
val phone = APIUtil.hostedByPhone
val organisationWebsite = APIUtil.organisationWebsite
val hostedBy = new HostedBy400(organisation, email, phone, organisationWebsite)
val organisationHostedAt = APIUtil.getPropsValue("hosted_at.organisation", "")
val organisationWebsiteHostedAt = APIUtil.getPropsValue("hosted_at.organisation_website", "")
val organisationHostedAt = APIUtil.hostedAtOrganisation
val organisationWebsiteHostedAt = APIUtil.hostedAtOrganisationWebsite
val hostedAt = HostedAt400(organisationHostedAt, organisationWebsiteHostedAt)
val organisationEnergySource = APIUtil.getPropsValue("energy_source.organisation", "")
val organisationWebsiteEnergySource = APIUtil.getPropsValue("energy_source.organisation_website", "")
val organisationEnergySource = APIUtil.energySourceOrganisation
val organisationWebsiteEnergySource = APIUtil.energySourceOrganisationWebsite
val energySource = EnergySource400(organisationEnergySource, organisationWebsiteEnergySource)
val connector = code.api.Constant.CONNECTOR.openOrThrowException(s"$MandatoryPropertyIsNotSet. The missing prop is `connector` ")
val resourceDocsRequiresRole = APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false)
val resourceDocsRequiresRole = APIUtil.resourceDocsRequiresRole
APIInfoJsonV700(
version = apiVersion.vDottedApiVersion,
version_status = "BLEEDING_EDGE",
git_commit = gitCommit,
version_status = apiVersionStatus,
git_commit = APIUtil.gitCommit,
connector = connector,
hostname = Constant.HostName,
stage = System.getProperty("run.mode"),
@ -64,8 +52,7 @@ object JSONFactory700 extends MdcLoggable {
hosted_by = hostedBy,
hosted_at = hostedAt,
energy_source = energySource,
resource_docs_requires_role = resourceDocsRequiresRole,
message = message
resource_docs_requires_role = resourceDocsRequiresRole
)
}
}