diff --git a/.gitignore b/.gitignore index 219ca48427..aaa6252009 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/obp-api/src/main/scala/code/api/directlogin.scala b/obp-api/src/main/scala/code/api/directlogin.scala index 77a1668d52..c2580c62c9 100644 --- a/obp-api/src/main/scala/code/api/directlogin.scala +++ b/obp-api/src/main/scala/code/api/directlogin.scala @@ -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)) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index ed4f0c9642..7ba6c81769 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -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) diff --git a/obp-api/src/main/scala/code/api/util/ApiSession.scala b/obp-api/src/main/scala/code/api/util/ApiSession.scala index 30946d18c3..e7426ed7bd 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -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(", ")})" diff --git a/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala new file mode 100644 index 0000000000..856b0f1ee7 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala @@ -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)) + ) + } +} diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala new file mode 100644 index 0000000000..f231ba002c --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala @@ -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) + ) + } +} diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index e69de29bb2..878d398dd7 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -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))) + } + } + } + } + } + } + } +} diff --git a/obp-api/src/main/scala/code/api/v4_0_0/JSONFactory4.0.0.scala b/obp-api/src/main/scala/code/api/v4_0_0/JSONFactory4.0.0.scala index 75aa0bd5f5..5ef812c7d4 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/JSONFactory4.0.0.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/JSONFactory4.0.0.scala @@ -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, diff --git a/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala b/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala index a5f01717ba..f1f36add98 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala @@ -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, diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 1f8388ebdf..55da729fcf 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -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(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 { - (banks, callContext) <- NewStyle.function.getBanks(None) - } yield { - convertAnyToJsonString(JSONFactory400.createBanksJson(banks)) - } - ))) + (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 } diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala index a675842e65..8bb51db931 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala @@ -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 ) } }