Merge remote-tracking branch 'Hongwei/develop-Simon' into develop

This commit is contained in:
hongwei 2026-01-07 15:26:39 +01:00
commit f14c7b00a9
18 changed files with 864 additions and 204 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.github/*
*.class
*.db
.DS_Store

View File

@ -76,7 +76,7 @@ MAVEN_OPTS="-Xms3G -Xmx6G -XX:MaxMetaspaceSize=2G" mvn -pl obp-http4s-runner -am
java -jar obp-http4s-runner/target/obp-http4s-runner.jar
```
The http4s server binds to `http4s.host` / `http4s.port` as configured in your props file (defaults are `127.0.0.1` and `8181`).
The http4s server binds to `http4s.host` / `http4s.port` as configured in your props file (defaults are `127.0.0.1` and `8086`).
### ZED IDE Setup

View File

@ -1,10 +1,13 @@
#!/bin/bash
# Script to flush Redis, build the project, and run Jetty
# Script to flush Redis, build the project, and run both Jetty and http4s servers
#
# This script should be run from the OBP-API root directory:
# cd /path/to/OBP-API
# ./flushall_build_and_run.sh
#
# The http4s server will run in the background on port 8081
# The Jetty server will run in the foreground on port 8080
set -e # Exit on error
@ -27,4 +30,29 @@ echo "=========================================="
echo "Building and running with Maven..."
echo "=========================================="
export MAVEN_OPTS="-Xss128m --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang.invoke=ALL-UNNAMED --add-opens java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED"
mvn install -pl .,obp-commons && mvn jetty:run -pl obp-api
mvn install -pl .,obp-commons
echo ""
echo "=========================================="
echo "Building http4s runner..."
echo "=========================================="
export MAVEN_OPTS="-Xms3G -Xmx6G -XX:MaxMetaspaceSize=2G"
mvn -pl obp-http4s-runner -am clean package -DskipTests=true -Dmaven.test.skip=true
echo ""
echo "=========================================="
echo "Starting http4s server in background..."
echo "=========================================="
java -jar obp-http4s-runner/target/obp-http4s-runner.jar > http4s-server.log 2>&1 &
HTTP4S_PID=$!
echo "http4s server started with PID: $HTTP4S_PID (port 8081)"
echo "Logs are being written to: http4s-server.log"
echo ""
echo "To stop http4s server later: kill $HTTP4S_PID"
echo ""
echo "=========================================="
echo "Starting Jetty server (foreground)..."
echo "=========================================="
export MAVEN_OPTS="-Xss128m --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang.invoke=ALL-UNNAMED --add-opens java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED"
mvn jetty:run -pl obp-api

View File

@ -1691,6 +1691,6 @@ securelogging_mask_email=true
############################################
# Host and port for http4s server (used by bootstrap.http4s.Http4sServer)
# Defaults (if not set) are 127.0.0.1 and 8181
# Defaults (if not set) are 127.0.0.1 and 8086
http4s.host=127.0.0.1
http4s.port=8086

View File

@ -17,8 +17,7 @@ object Http4sServer extends IOApp {
val port = APIUtil.getPropsAsIntValue("http4s.port",8086)
val host = APIUtil.getPropsValue("http4s.host","127.0.0.1")
val services: Kleisli[({type λ[β$0$] = OptionT[IO, β$0$]})#λ, Request[IO], Response[IO]] =
code.api.v7_0_0.Http4s700.wrappedRoutesV700Services
val services: HttpRoutes[IO] = code.api.v7_0_0.Http4s700.wrappedRoutesV700Services
val httpApp: Kleisli[IO, Request[IO], Response[IO]] = (services).orNotFound

View File

@ -228,6 +228,71 @@ object OAuth2Login extends RestHelper with MdcLoggable {
def urlOfJwkSets: Box[String] = Constant.oauth2JwkSetUrl
/**
* Get all JWKS URLs from configuration.
* This is a helper method for trying multiple JWKS URLs when validating tokens.
* We need more than one JWKS URL if we have multiple OIDC providers configured etc.
* @return List of all configured JWKS URLs
*/
protected def getAllJwksUrls: List[String] = {
val url: List[String] = Constant.oauth2JwkSetUrl.toList
url.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty)
}
/**
* Try to validate a JWT token with multiple JWKS URLs.
* This is a generic retry mechanism that works for both ID tokens and access tokens.
*
* @param token The JWT token to validate
* @param tokenType Description of token type for logging (e.g., "ID token", "access token")
* @param validateFunc Function that validates token against a JWKS URL
* @tparam T The type of claims returned (IDTokenClaimsSet or JWTClaimsSet)
* @return Boxed claims or failure
*/
protected def tryValidateWithAllJwksUrls[T](
token: String,
tokenType: String,
validateFunc: (String, String) => Box[T]
): Box[T] = {
logger.debug(s"tryValidateWithAllJwksUrls - attempting to validate $tokenType")
// Extract issuer for better error reporting
val actualIssuer = JwtUtil.getIssuer(token).getOrElse("NO_ISSUER_CLAIM")
logger.debug(s"tryValidateWithAllJwksUrls - JWT issuer claim: '$actualIssuer'")
// Get all JWKS URLs
val allJwksUrls = getAllJwksUrls
if (allJwksUrls.isEmpty) {
logger.debug(s"tryValidateWithAllJwksUrls - No JWKS URLs configured")
return Failure(Oauth2ThereIsNoUrlOfJwkSet)
}
logger.debug(s"tryValidateWithAllJwksUrls - Will try ${allJwksUrls.size} JWKS URL(s): $allJwksUrls")
// Try each JWKS URL until one succeeds
val results = allJwksUrls.map { url =>
logger.debug(s"tryValidateWithAllJwksUrls - Trying JWKS URL: '$url'")
val result = validateFunc(token, url)
result match {
case Full(_) =>
logger.debug(s"tryValidateWithAllJwksUrls - SUCCESS with JWKS URL: '$url'")
case Failure(msg, _, _) =>
logger.debug(s"tryValidateWithAllJwksUrls - FAILED with JWKS URL: '$url', reason: $msg")
case _ =>
logger.debug(s"tryValidateWithAllJwksUrls - FAILED with JWKS URL: '$url'")
}
result
}
// Return the first successful result, or the last failure
results.find(_.isDefined).getOrElse {
logger.debug(s"tryValidateWithAllJwksUrls - All ${allJwksUrls.size} JWKS URL(s) failed for issuer: '$actualIssuer'")
logger.debug(s"tryValidateWithAllJwksUrls - Tried URLs: $allJwksUrls")
results.lastOption.getOrElse(Failure(Oauth2ThereIsNoUrlOfJwkSet))
}
}
def checkUrlOfJwkSets(identityProvider: String) = {
val url: List[String] = Constant.oauth2JwkSetUrl.toList
val jwksUris: List[String] = url.map(_.toLowerCase()).map(_.split(",").toList).flatten
@ -310,47 +375,10 @@ object OAuth2Login extends RestHelper with MdcLoggable {
}.getOrElse(false)
}
def validateIdToken(idToken: String): Box[IDTokenClaimsSet] = {
logger.debug(s"validateIdToken - attempting to validate ID token")
// Extract issuer for better error reporting
val actualIssuer = JwtUtil.getIssuer(idToken).getOrElse("NO_ISSUER_CLAIM")
logger.debug(s"validateIdToken - JWT issuer claim: '$actualIssuer'")
urlOfJwkSets match {
case Full(url) =>
logger.debug(s"validateIdToken - using JWKS URL: '$url'")
JwtUtil.validateIdToken(idToken, url)
case ParamFailure(a, b, c, apiFailure : APIFailure) =>
logger.debug(s"validateIdToken - ParamFailure: $a, $b, $c, $apiFailure")
logger.debug(s"validateIdToken - JWT issuer was: '$actualIssuer'")
ParamFailure(a, b, c, apiFailure : APIFailure)
case Failure(msg, t, c) =>
logger.debug(s"validateIdToken - Failure getting JWKS URL: $msg")
logger.debug(s"validateIdToken - JWT issuer was: '$actualIssuer'")
if (msg.contains("OBP-20208")) {
logger.debug("validateIdToken - OBP-20208 Error Details:")
logger.debug(s"validateIdToken - JWT issuer claim: '$actualIssuer'")
logger.debug(s"validateIdToken - oauth2.jwk_set.url value: '${Constant.oauth2JwkSetUrl}'")
logger.debug("validateIdToken - Check that the JWKS URL configuration matches the JWT issuer")
}
Failure(msg, t, c)
case _ =>
logger.debug("validateIdToken - No JWKS URL available")
logger.debug(s"validateIdToken - JWT issuer was: '$actualIssuer'")
Failure(Oauth2ThereIsNoUrlOfJwkSet)
}
tryValidateWithAllJwksUrls(idToken, "ID token", JwtUtil.validateIdToken)
}
def validateAccessToken(accessToken: String): Box[JWTClaimsSet] = {
urlOfJwkSets match {
case Full(url) =>
JwtUtil.validateAccessToken(accessToken, url)
case ParamFailure(a, b, c, apiFailure : APIFailure) =>
ParamFailure(a, b, c, apiFailure : APIFailure)
case Failure(msg, t, c) =>
Failure(msg, t, c)
case _ =>
Failure(Oauth2ThereIsNoUrlOfJwkSet)
}
tryValidateWithAllJwksUrls(accessToken, "access token", JwtUtil.validateAccessToken)
}
/** New Style Endpoints
* This function creates user based on "iss" and "sub" fields

View File

@ -208,11 +208,16 @@ object ResourceDocs300 extends OBPRestHelper with ResourceDocsAPIMethods with Md
val resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(resourceDocs, isVersion4OrHigher, locale)
resourceDocsJson.resource_docs
case _ =>
// Get all resource docs for the requested version
val allResourceDocs = ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(List.empty)
val filteredResourceDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(allResourceDocs, resourceDocTags, partialFunctions)
val resourceDocJson = JSONFactory1_4_0.createResourceDocsJson(filteredResourceDocs, isVersion4OrHigher, locale)
resourceDocJson.resource_docs
contentParam match {
case Some(DYNAMIC) =>
ImplementationsResourceDocs.getResourceDocsObpDynamicCached(resourceDocTags, partialFunctions, locale, None, isVersion4OrHigher).head.resource_docs
case Some(STATIC) => {
ImplementationsResourceDocs.getStaticResourceDocsObpCached(requestedApiVersionString, resourceDocTags, partialFunctions, locale, isVersion4OrHigher).head.resource_docs
}
case _ => {
ImplementationsResourceDocs.getAllResourceDocsObpCached(requestedApiVersionString, resourceDocTags, partialFunctions, locale, contentParam, isVersion4OrHigher).head.resource_docs
}
}
}
val hostname = HostName

View File

@ -230,7 +230,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth
* @param contentParam if this is Some(`true`), only show dynamic endpoints, if Some(`false`), only show static. If it is None, we will show all. default is None
* @return
*/
private def getStaticResourceDocsObpCached(
def getStaticResourceDocsObpCached(
requestedApiVersionString: String,
resourceDocTags: Option[List[ResourceDocTag]],
partialFunctionNames: Option[List[String]],
@ -250,7 +250,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth
* @param contentParam if this is Some(`true`), only show dynamic endpoints, if Some(`false`), only show static. If it is None, we will show all. default is None
* @return
*/
private def getAllResourceDocsObpCached(
def getAllResourceDocsObpCached(
requestedApiVersionString: String,
resourceDocTags: Option[List[ResourceDocTag]],
partialFunctionNames: Option[List[String]],
@ -293,7 +293,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth
}
private def getResourceDocsObpDynamicCached(
def getResourceDocsObpDynamicCached(
resourceDocTags: Option[List[ResourceDocTag]],
partialFunctionNames: Option[List[String]],
locale: Option[String],

View File

@ -25,4 +25,22 @@ object InMemory extends MdcLoggable {
logger.trace(s"InMemory.memoizeWithInMemory.underlyingGuavaCache size ${underlyingGuavaCache.size()}, current cache key is $cacheKey")
memoize(ttl)(f)
}
/**
* Count keys matching a pattern in the in-memory cache
* @param pattern Pattern to match (supports * wildcard)
* @return Number of matching keys
*/
def countKeys(pattern: String): Int = {
try {
val regex = pattern.replace("*", ".*").r
val allKeys = underlyingGuavaCache.asMap().keySet()
import scala.collection.JavaConverters._
allKeys.asScala.count(key => regex.pattern.matcher(key).matches())
} catch {
case e: Throwable =>
logger.error(s"Error counting in-memory cache keys for pattern $pattern: ${e.getMessage}")
0
}
}
}

View File

@ -73,12 +73,12 @@ object RedisLogger {
/** Map a LogLevel to its required entitlements */
def requiredRoles(level: LogLevel): List[ApiRole] = level match {
case TRACE => List(canGetTraceLevelLogsAtAllBanks, canGetAllLevelLogsAtAllBanks)
case DEBUG => List(canGetDebugLevelLogsAtAllBanks, canGetAllLevelLogsAtAllBanks)
case INFO => List(canGetInfoLevelLogsAtAllBanks, canGetAllLevelLogsAtAllBanks)
case WARNING => List(canGetWarningLevelLogsAtAllBanks, canGetAllLevelLogsAtAllBanks)
case ERROR => List(canGetErrorLevelLogsAtAllBanks, canGetAllLevelLogsAtAllBanks)
case ALL => List(canGetAllLevelLogsAtAllBanks)
case TRACE => List(canGetSystemLogCacheTrace, canGetSystemLogCacheAll)
case DEBUG => List(canGetSystemLogCacheDebug, canGetSystemLogCacheAll)
case INFO => List(canGetSystemLogCacheInfo, canGetSystemLogCacheAll)
case WARNING => List(canGetSystemLogCacheWarning, canGetSystemLogCacheAll)
case ERROR => List(canGetSystemLogCacheError, canGetSystemLogCacheAll)
case ALL => List(canGetSystemLogCacheAll)
}
}

View File

@ -107,35 +107,23 @@ object ApiRole extends MdcLoggable{
// TRACE
case class CanGetTraceLevelLogsAtOneBank(requiresBankId: Boolean = true) extends ApiRole
lazy val canGetTraceLevelLogsAtOneBank = CanGetTraceLevelLogsAtOneBank()
case class CanGetTraceLevelLogsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetTraceLevelLogsAtAllBanks = CanGetTraceLevelLogsAtAllBanks()
case class CanGetSystemLogCacheTrace(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetSystemLogCacheTrace = CanGetSystemLogCacheTrace()
// DEBUG
case class CanGetDebugLevelLogsAtOneBank(requiresBankId: Boolean = true) extends ApiRole
lazy val canGetDebugLevelLogsAtOneBank = CanGetDebugLevelLogsAtOneBank()
case class CanGetDebugLevelLogsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetDebugLevelLogsAtAllBanks = CanGetDebugLevelLogsAtAllBanks()
case class CanGetSystemLogCacheDebug(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetSystemLogCacheDebug = CanGetSystemLogCacheDebug()
// INFO
case class CanGetInfoLevelLogsAtOneBank(requiresBankId: Boolean = true) extends ApiRole
lazy val canGetInfoLevelLogsAtOneBank = CanGetInfoLevelLogsAtOneBank()
case class CanGetInfoLevelLogsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetInfoLevelLogsAtAllBanks = CanGetInfoLevelLogsAtAllBanks()
case class CanGetSystemLogCacheInfo(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetSystemLogCacheInfo = CanGetSystemLogCacheInfo()
// WARNING
case class CanGetWarningLevelLogsAtOneBank(requiresBankId: Boolean = true) extends ApiRole
lazy val canGetWarningLevelLogsAtOneBank = CanGetWarningLevelLogsAtOneBank()
case class CanGetWarningLevelLogsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetWarningLevelLogsAtAllBanks = CanGetWarningLevelLogsAtAllBanks()
case class CanGetSystemLogCacheWarning(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetSystemLogCacheWarning = CanGetSystemLogCacheWarning()
// ERROR
case class CanGetErrorLevelLogsAtOneBank(requiresBankId: Boolean = true) extends ApiRole
lazy val canGetErrorLevelLogsAtOneBank = CanGetErrorLevelLogsAtOneBank()
case class CanGetErrorLevelLogsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetErrorLevelLogsAtAllBanks = CanGetErrorLevelLogsAtAllBanks()
case class CanGetSystemLogCacheError(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetSystemLogCacheError = CanGetSystemLogCacheError()
// ALL
case class CanGetAllLevelLogsAtOneBank(requiresBankId: Boolean = true) extends ApiRole
lazy val canGetAllLevelLogsAtOneBank = CanGetAllLevelLogsAtOneBank()
case class CanGetAllLevelLogsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetAllLevelLogsAtAllBanks = CanGetAllLevelLogsAtAllBanks()
case class CanGetSystemLogCacheAll(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetSystemLogCacheAll = CanGetSystemLogCacheAll()
case class CanUpdateAgentStatusAtAnyBank(requiresBankId: Boolean = false) extends ApiRole
lazy val canUpdateAgentStatusAtAnyBank = CanUpdateAgentStatusAtAnyBank()

View File

@ -91,6 +91,7 @@ object ApiTag {
val apiTagDevOps = ResourceDocTag("DevOps")
val apiTagSystem = ResourceDocTag("System")
val apiTagCache = ResourceDocTag("Cache")
val apiTagLogCache = ResourceDocTag("Log-Cache")
val apiTagApiCollection = ResourceDocTag("Api-Collection")

View File

@ -84,6 +84,7 @@ object ErrorMessages {
val FXCurrencyCodeCombinationsNotSupported = "OBP-10004: ISO Currency code combination not supported for FX. Please modify the FROM_CURRENCY_CODE or TO_CURRENCY_CODE. "
val InvalidDateFormat = "OBP-10005: Invalid Date Format. Could not convert value to a Date."
val InvalidCurrency = "OBP-10006: Invalid Currency Value."
val InvalidCacheNamespaceId = "OBP-10123: Invalid namespace_id."
val IncorrectRoleName = "OBP-10007: Incorrect Role name:"
val CouldNotTransformJsonToInternalModel = "OBP-10008: Could not transform Json to internal model."
val CountNotSaveOrUpdateResource = "OBP-10009: Could not save or update resource."
@ -269,7 +270,7 @@ object ErrorMessages {
val Oauth2ThereIsNoUrlOfJwkSet = "OBP-20203: There is no an URL of OAuth 2.0 server's JWK set, published at a well-known URL."
val Oauth2BadJWTException = "OBP-20204: Bad JWT error. "
val Oauth2ParseException = "OBP-20205: Parse error. "
val Oauth2BadJOSEException = "OBP-20206: Bad JSON Object Signing and Encryption (JOSE) exception. The ID token is invalid or expired. "
val Oauth2BadJOSEException = "OBP-20206: Bad JSON Object Signing and Encryption (JOSE) exception. The ID token is invalid or expired. OBP-API Admin should check the oauth2.jwk_set.url list contains the jwks url of the provider."
val Oauth2JOSEException = "OBP-20207: Bad JSON Object Signing and Encryption (JOSE) exception. An internal JOSE exception was encountered. "
val Oauth2CannotMatchIssuerAndJwksUriException = "OBP-20208: Cannot match the issuer and JWKS URI at this server instance. "
val Oauth2TokenHaveNoConsumer = "OBP-20209: The token have no linked consumer. "

View File

@ -238,55 +238,204 @@ trait APIMethods510 {
}
}
// Helper function to avoid code duplication
private def getLogCacheHelper(level: RedisLogger.LogLevel.Value, cc: CallContext): Future[(RedisLogger.LogTail, Option[CallContext])] = {
implicit val ec = EndpointContext(Some(cc))
for {
httpParams <- NewStyle.function.extractHttpParamsFromUrl(cc.url)
(obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(httpParams, cc.callContext)
limit = obpQueryParams.collectFirst { case OBPLimit(value) => value }
offset = obpQueryParams.collectFirst { case OBPOffset(value) => value }
logs <- Future(RedisLogger.getLogTail(level, limit, offset))
} yield {
(logs, HttpCode.`200`(callContext))
}
}
staticResourceDocs += ResourceDoc(
logCacheEndpoint,
logCacheTraceEndpoint,
implementedInApiVersion,
nameOf(logCacheEndpoint),
nameOf(logCacheTraceEndpoint),
"GET",
"/system/log-cache/LOG_LEVEL",
"Get Log Cache",
"""Returns information about:
|
|* Log Cache
"/system/log-cache/trace",
"Get Trace Level Log Cache",
"""Returns TRACE level logs from the system log cache.
|
|This endpoint supports pagination via the following optional query parameters:
|* limit - Maximum number of log entries to return
|* offset - Number of log entries to skip (for pagination)
|
|Example: GET /system/log-cache/INFO?limit=50&offset=100
|Example: GET /system/log-cache/trace?limit=50&offset=100
""",
EmptyBody,
EmptyBody,
List($UserNotLoggedIn, UnknownError),
apiTagSystem :: apiTagApi :: Nil,
Some(List(canGetAllLevelLogsAtAllBanks)))
apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil,
Some(List(canGetSystemLogCacheTrace, canGetSystemLogCacheAll)))
lazy val logCacheEndpoint: OBPEndpoint = {
case "system" :: "log-cache" :: logLevel :: Nil JsonGet _ =>
lazy val logCacheTraceEndpoint: OBPEndpoint = {
case "system" :: "log-cache" :: "trace" :: Nil JsonGet _ =>
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
// Parse and validate log level
level <- NewStyle.function.tryons(ErrorMessages.invalidLogLevel, 400, cc.callContext) {
RedisLogger.LogLevel.valueOf(logLevel)
}
// Check entitlements using helper
_ <- NewStyle.function.handleEntitlementsAndScopes(
bankId = "",
userId = cc.userId,
roles = RedisLogger.LogLevel.requiredRoles(level),
callContext = cc.callContext
)
httpParams <- NewStyle.function.extractHttpParamsFromUrl(cc.url)
(obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(httpParams, cc.callContext)
// Extract limit and offset from query parameters
limit = obpQueryParams.collectFirst { case OBPLimit(value) => value }
offset = obpQueryParams.collectFirst { case OBPOffset(value) => value }
// Fetch logs with pagination
logs <- Future(RedisLogger.getLogTail(level, limit, offset))
} yield {
(logs, HttpCode.`200`(cc.callContext))
}
_ <- NewStyle.function.handleEntitlementsAndScopes("", cc.userId, List(canGetSystemLogCacheTrace, canGetSystemLogCacheAll), cc.callContext)
result <- getLogCacheHelper(RedisLogger.LogLevel.TRACE, cc)
} yield result
}
staticResourceDocs += ResourceDoc(
logCacheDebugEndpoint,
implementedInApiVersion,
nameOf(logCacheDebugEndpoint),
"GET",
"/system/log-cache/debug",
"Get Debug Level Log Cache",
"""Returns DEBUG level logs from the system log cache.
|
|This endpoint supports pagination via the following optional query parameters:
|* limit - Maximum number of log entries to return
|* offset - Number of log entries to skip (for pagination)
|
|Example: GET /system/log-cache/debug?limit=50&offset=100
""",
EmptyBody,
EmptyBody,
List($UserNotLoggedIn, UnknownError),
apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil,
Some(List(canGetSystemLogCacheDebug, canGetSystemLogCacheAll)))
lazy val logCacheDebugEndpoint: OBPEndpoint = {
case "system" :: "log-cache" :: "debug" :: Nil JsonGet _ =>
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
_ <- NewStyle.function.handleEntitlementsAndScopes("", cc.userId, List(canGetSystemLogCacheDebug, canGetSystemLogCacheAll), cc.callContext)
result <- getLogCacheHelper(RedisLogger.LogLevel.DEBUG, cc)
} yield result
}
staticResourceDocs += ResourceDoc(
logCacheInfoEndpoint,
implementedInApiVersion,
nameOf(logCacheInfoEndpoint),
"GET",
"/system/log-cache/info",
"Get Info Level Log Cache",
"""Returns INFO level logs from the system log cache.
|
|This endpoint supports pagination via the following optional query parameters:
|* limit - Maximum number of log entries to return
|* offset - Number of log entries to skip (for pagination)
|
|Example: GET /system/log-cache/info?limit=50&offset=100
""",
EmptyBody,
EmptyBody,
List($UserNotLoggedIn, UnknownError),
apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil,
Some(List(canGetSystemLogCacheInfo, canGetSystemLogCacheAll)))
lazy val logCacheInfoEndpoint: OBPEndpoint = {
case "system" :: "log-cache" :: "info" :: Nil JsonGet _ =>
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
_ <- NewStyle.function.handleEntitlementsAndScopes("", cc.userId, List(canGetSystemLogCacheInfo, canGetSystemLogCacheAll), cc.callContext)
result <- getLogCacheHelper(RedisLogger.LogLevel.INFO, cc)
} yield result
}
staticResourceDocs += ResourceDoc(
logCacheWarningEndpoint,
implementedInApiVersion,
nameOf(logCacheWarningEndpoint),
"GET",
"/system/log-cache/warning",
"Get Warning Level Log Cache",
"""Returns WARNING level logs from the system log cache.
|
|This endpoint supports pagination via the following optional query parameters:
|* limit - Maximum number of log entries to return
|* offset - Number of log entries to skip (for pagination)
|
|Example: GET /system/log-cache/warning?limit=50&offset=100
""",
EmptyBody,
EmptyBody,
List($UserNotLoggedIn, UnknownError),
apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil,
Some(List(canGetSystemLogCacheWarning, canGetSystemLogCacheAll)))
lazy val logCacheWarningEndpoint: OBPEndpoint = {
case "system" :: "log-cache" :: "warning" :: Nil JsonGet _ =>
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
_ <- NewStyle.function.handleEntitlementsAndScopes("", cc.userId, List(canGetSystemLogCacheWarning, canGetSystemLogCacheAll), cc.callContext)
result <- getLogCacheHelper(RedisLogger.LogLevel.WARNING, cc)
} yield result
}
staticResourceDocs += ResourceDoc(
logCacheErrorEndpoint,
implementedInApiVersion,
nameOf(logCacheErrorEndpoint),
"GET",
"/system/log-cache/error",
"Get Error Level Log Cache",
"""Returns ERROR level logs from the system log cache.
|
|This endpoint supports pagination via the following optional query parameters:
|* limit - Maximum number of log entries to return
|* offset - Number of log entries to skip (for pagination)
|
|Example: GET /system/log-cache/error?limit=50&offset=100
""",
EmptyBody,
EmptyBody,
List($UserNotLoggedIn, UnknownError),
apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil,
Some(List(canGetSystemLogCacheError, canGetSystemLogCacheAll)))
lazy val logCacheErrorEndpoint: OBPEndpoint = {
case "system" :: "log-cache" :: "error" :: Nil JsonGet _ =>
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
_ <- NewStyle.function.handleEntitlementsAndScopes("", cc.userId, List(canGetSystemLogCacheError, canGetSystemLogCacheAll), cc.callContext)
result <- getLogCacheHelper(RedisLogger.LogLevel.ERROR, cc)
} yield result
}
staticResourceDocs += ResourceDoc(
logCacheAllEndpoint,
implementedInApiVersion,
nameOf(logCacheAllEndpoint),
"GET",
"/system/log-cache/all",
"Get All Level Log Cache",
"""Returns logs of all levels from the system log cache.
|
|This endpoint supports pagination via the following optional query parameters:
|* limit - Maximum number of log entries to return
|* offset - Number of log entries to skip (for pagination)
|
|Example: GET /system/log-cache/all?limit=50&offset=100
""",
EmptyBody,
EmptyBody,
List($UserNotLoggedIn, UnknownError),
apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil,
Some(List(canGetSystemLogCacheAll)))
lazy val logCacheAllEndpoint: OBPEndpoint = {
case "system" :: "log-cache" :: "all" :: Nil JsonGet _ =>
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
_ <- NewStyle.function.handleEntitlementsAndScopes("", cc.userId, List(canGetSystemLogCacheAll), cc.callContext)
result <- getLogCacheHelper(RedisLogger.LogLevel.ALL, cc)
} yield result
}

View File

@ -27,7 +27,7 @@ import code.api.v5_0_0.{ViewJsonV500, ViewsJsonV500}
import code.api.v5_1_0.{JSONFactory510, PostCustomerLegalNameJsonV510}
import code.api.dynamic.entity.helper.{DynamicEntityHelper, DynamicEntityInfo}
import code.api.v6_0_0.JSONFactory600.{AddUserToGroupResponseJsonV600, DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, GroupEntitlementJsonV600, GroupEntitlementsJsonV600, GroupJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ScannedApiVersionJsonV600, UpdateViewJsonV600, UserGroupMembershipJsonV600, UserGroupMembershipsJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, ViewJsonV600, ViewPermissionJsonV600, ViewPermissionsJsonV600, ViewsJsonV600, createAbacRuleJsonV600, createAbacRulesJsonV600, createActiveRateLimitsJsonV600, createCallLimitJsonV600, createRedisCallCountersJson}
import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CacheProviderConfigJsonV600, CreateAbacRuleJsonV600, CurrentConsumerJsonV600, ExecuteAbacRuleJsonV600, UpdateAbacRuleJsonV600}
import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CreateAbacRuleJsonV600, CurrentConsumerJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, RedisCacheStatusJsonV600, UpdateAbacRuleJsonV600}
import code.api.v6_0_0.OBPAPI6_0_0
import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider}
import code.metrics.APIMetrics
@ -635,7 +635,7 @@ trait APIMethods600 {
}
namespaceId = postJson.namespace_id
_ <- Helper.booleanToFuture(
s"Invalid namespace_id: $namespaceId. Valid values: ${Constant.ALL_CACHE_NAMESPACES.mkString(", ")}",
s"$InvalidCacheNamespaceId $namespaceId. Valid values: ${Constant.ALL_CACHE_NAMESPACES.mkString(", ")}",
400,
callContext
)(Constant.ALL_CACHE_NAMESPACES.contains(namespaceId))
@ -667,8 +667,8 @@ trait APIMethods600 {
"Get Cache Configuration",
"""Returns cache configuration information including:
|
|- Available cache providers (Redis, In-Memory)
|- Redis connection details (URL, port, SSL)
|- Redis status: availability, connection details (URL, port, SSL)
|- In-memory cache status: availability and current size
|- Instance ID and environment
|- Global cache namespace prefix
|
@ -678,21 +678,15 @@ trait APIMethods600 {
|""",
EmptyBody,
CacheConfigJsonV600(
providers = List(
CacheProviderConfigJsonV600(
provider = "redis",
enabled = true,
url = Some("127.0.0.1"),
port = Some(6379),
use_ssl = Some(false)
),
CacheProviderConfigJsonV600(
provider = "in_memory",
enabled = true,
url = None,
port = None,
use_ssl = None
)
redis_status = RedisCacheStatusJsonV600(
available = true,
url = "127.0.0.1",
port = 6379,
use_ssl = false
),
in_memory_status = InMemoryCacheStatusJsonV600(
available = true,
current_size = 42
),
instance_id = "obp",
environment = "dev",
@ -733,6 +727,14 @@ trait APIMethods600 {
|- Current version counter
|- Number of keys in each namespace
|- Description and category
|- Storage location (redis, memory, both, or unknown)
| - "redis": Keys stored in Redis
| - "memory": Keys stored in in-memory cache
| - "both": Keys in both locations (indicates a BUG - should never happen)
| - "unknown": No keys found, storage location cannot be determined
|- TTL info: Sampled TTL information from actual keys
| - Shows actual TTL values from up to 5 sample keys
| - Format: "123s" (fixed), "range 60s to 3600s (avg 1800s)" (variable), "no expiry" (persistent)
|- Total key count across all namespaces
|- Redis availability status
|
@ -749,7 +751,9 @@ trait APIMethods600 {
current_version = 1,
key_count = 42,
description = "Rate limit call counters",
category = "Rate Limiting"
category = "Rate Limiting",
storage_location = "redis",
ttl_info = "range 60s to 86400s (avg 3600s)"
),
CacheNamespaceInfoJsonV600(
namespace_id = "rd_localised",
@ -757,7 +761,9 @@ trait APIMethods600 {
current_version = 1,
key_count = 128,
description = "Localized resource docs",
category = "API Documentation"
category = "API Documentation",
storage_location = "redis",
ttl_info = "3600s"
)
),
total_keys = 170,

View File

@ -268,16 +268,21 @@ case class InvalidatedCacheNamespaceJsonV600(
status: String
)
case class CacheProviderConfigJsonV600(
provider: String,
enabled: Boolean,
url: Option[String],
port: Option[Int],
use_ssl: Option[Boolean]
case class RedisCacheStatusJsonV600(
available: Boolean,
url: String,
port: Int,
use_ssl: Boolean
)
case class InMemoryCacheStatusJsonV600(
available: Boolean,
current_size: Long
)
case class CacheConfigJsonV600(
providers: List[CacheProviderConfigJsonV600],
redis_status: RedisCacheStatusJsonV600,
in_memory_status: InMemoryCacheStatusJsonV600,
instance_id: String,
environment: String,
global_prefix: String
@ -289,7 +294,9 @@ case class CacheNamespaceInfoJsonV600(
current_version: Long,
key_count: Int,
description: String,
category: String
category: String,
storage_location: String,
ttl_info: String
)
case class CacheInfoJsonV600(
@ -1119,21 +1126,17 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable {
import code.api.Constant
import net.liftweb.util.Props
val redisProvider = CacheProviderConfigJsonV600(
provider = "redis",
enabled = true,
url = Some(Redis.url),
port = Some(Redis.port),
use_ssl = Some(Redis.useSsl)
)
val redisIsReady = try {
Redis.isRedisReady
} catch {
case _: Throwable => false
}
val inMemoryProvider = CacheProviderConfigJsonV600(
provider = "in_memory",
enabled = true,
url = None,
port = None,
use_ssl = None
)
val inMemorySize = try {
InMemory.underlyingGuavaCache.size()
} catch {
case _: Throwable => 0L
}
val instanceId = code.api.util.APIUtil.getPropsValue("api_instance_id").getOrElse("obp")
val environment = Props.mode match {
@ -1144,8 +1147,21 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable {
case _ => "unknown"
}
val redisStatus = RedisCacheStatusJsonV600(
available = redisIsReady,
url = Redis.url,
port = Redis.port,
use_ssl = Redis.useSsl
)
val inMemoryStatus = InMemoryCacheStatusJsonV600(
available = inMemorySize >= 0,
current_size = inMemorySize
)
CacheConfigJsonV600(
providers = List(redisProvider, inMemoryProvider),
redis_status = redisStatus,
in_memory_status = inMemoryStatus,
instance_id = instanceId,
environment = environment,
global_prefix = Constant.getGlobalCacheNamespacePrefix
@ -1153,8 +1169,9 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable {
}
def createCacheInfoJsonV600(): CacheInfoJsonV600 = {
import code.api.cache.Redis
import code.api.cache.{Redis, InMemory}
import code.api.Constant
import code.api.JedisMethod
val namespaceDescriptions = Map(
Constant.CALL_COUNTER_NAMESPACE -> ("Rate limit call counters", "Rate Limiting"),
@ -1178,14 +1195,69 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable {
val prefix = Constant.getVersionedCachePrefix(namespaceId)
val pattern = s"${prefix}*"
val keyCount = try {
val count = Redis.countKeys(pattern)
totalKeys += count
count
// Dynamically determine storage location by checking where keys exist
var redisKeyCount = 0
var memoryKeyCount = 0
var storageLocation = "unknown"
var ttlInfo = "no keys to sample"
try {
redisKeyCount = Redis.countKeys(pattern)
totalKeys += redisKeyCount
// Sample keys to get TTL information
if (redisKeyCount > 0) {
val sampleKeys = Redis.scanKeys(pattern).take(5)
val ttls = sampleKeys.flatMap { key =>
Redis.use(JedisMethod.TTL, key, None, None).map(_.toLong)
}
if (ttls.nonEmpty) {
val minTtl = ttls.min
val maxTtl = ttls.max
val avgTtl = ttls.sum / ttls.length.toLong
ttlInfo = if (minTtl == maxTtl) {
if (minTtl == -1) "no expiry"
else if (minTtl == -2) "keys expired or missing"
else s"${minTtl}s"
} else {
s"range ${minTtl}s to ${maxTtl}s (avg ${avgTtl}s)"
}
}
}
} catch {
case _: Throwable =>
redisAvailable = false
0
}
try {
memoryKeyCount = InMemory.countKeys(pattern)
totalKeys += memoryKeyCount
if (memoryKeyCount > 0 && redisKeyCount == 0) {
ttlInfo = "in-memory (no TTL in Guava cache)"
}
} catch {
case _: Throwable =>
// In-memory cache error (shouldn't happen, but handle gracefully)
}
// Determine storage based on where keys actually exist
val keyCount = if (redisKeyCount > 0 && memoryKeyCount > 0) {
storageLocation = "both"
ttlInfo = s"redis: ${ttlInfo}, memory: in-memory cache"
redisKeyCount + memoryKeyCount
} else if (redisKeyCount > 0) {
storageLocation = "redis"
redisKeyCount
} else if (memoryKeyCount > 0) {
storageLocation = "memory"
memoryKeyCount
} else {
// No keys found in either location - we don't know where they would be stored
storageLocation = "unknown"
0
}
val (description, category) = namespaceDescriptions.getOrElse(namespaceId, ("Unknown namespace", "Other"))
@ -1196,7 +1268,9 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable {
current_version = version,
key_count = keyCount,
description = description,
category = category
category = category,
storage_location = storageLocation,
ttl_info = ttlInfo
)
}

View File

@ -1,7 +1,7 @@
package code.api.v5_1_0
import code.api.util.APIUtil.OAuth._
import code.api.util.ApiRole.CanGetAllLevelLogsAtAllBanks
import code.api.util.ApiRole.{CanGetSystemLogCacheAll,CanGetSystemLogCacheInfo}
import code.api.util.ErrorMessages.{UserHasMissingRoles, UserNotLoggedIn}
import code.api.v5_1_0.OBPAPI5_1_0.Implementations5_1_0
import code.entitlement.Entitlement
@ -21,12 +21,12 @@ class LogCacheEndpointTest extends V510ServerSetup {
* This is made possible by the scalatest maven plugin
*/
object VersionOfApi extends Tag(ApiVersion.v5_1_0.toString)
object ApiEndpoint1 extends Tag(nameOf(Implementations5_1_0.logCacheEndpoint))
object ApiEndpoint1 extends Tag(nameOf(Implementations5_1_0.logCacheInfoEndpoint))
feature(s"test $ApiEndpoint1 version $VersionOfApi - Unauthorized access") {
scenario("We will call the endpoint without user credentials", ApiEndpoint1, VersionOfApi) {
When("We make a request v5.1.0")
val request = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET
val request = (v5_1_0_Request / "system" / "log-cache" / "info").GET
val response = makeGetRequest(request)
Then("We should get a 401")
response.code should equal(401)
@ -37,21 +37,23 @@ class LogCacheEndpointTest extends V510ServerSetup {
feature(s"test $ApiEndpoint1 version $VersionOfApi - Missing entitlement") {
scenario("We will call the endpoint with user credentials but without proper entitlement", ApiEndpoint1, VersionOfApi) {
When("We make a request v5.1.0")
val request = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1)
val request = (v5_1_0_Request / "system" / "log-cache" / "info").GET <@(user1)
val response = makeGetRequest(request)
Then("error should be " + UserHasMissingRoles + CanGetAllLevelLogsAtAllBanks)
Then("error should be " + UserHasMissingRoles + CanGetSystemLogCacheAll)
response.code should equal(403)
response.body.extract[ErrorMessage].message should be(UserHasMissingRoles + CanGetAllLevelLogsAtAllBanks)
response.body.extract[ErrorMessage].message contains (UserHasMissingRoles) shouldBe (true)
response.body.extract[ErrorMessage].message contains CanGetSystemLogCacheInfo.toString() shouldBe (true)
response.body.extract[ErrorMessage].message contains CanGetSystemLogCacheAll.toString() shouldBe (true)
}
}
feature(s"test $ApiEndpoint1 version $VersionOfApi - Authorized access without pagination") {
scenario("We get log cache without pagination parameters", ApiEndpoint1, VersionOfApi) {
Given("We have a user with proper entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAllLevelLogsAtAllBanks.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLogCacheAll.toString)
When("We make a request to get log cache")
val request = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1)
val request = (v5_1_0_Request / "system" / "log-cache" / "info").GET <@(user1)
val response = makeGetRequest(request)
Then("We should get a successful response")
@ -66,10 +68,10 @@ class LogCacheEndpointTest extends V510ServerSetup {
feature(s"test $ApiEndpoint1 version $VersionOfApi - Authorized access with limit parameter") {
scenario("We get log cache with limit parameter only", ApiEndpoint1, VersionOfApi) {
Given("We have a user with proper entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAllLevelLogsAtAllBanks.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLogCacheAll.toString)
When("We make a request with limit parameter")
val request = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1) <<? List(("limit", "5"))
val request = (v5_1_0_Request / "system" / "log-cache" / "info").GET <@(user1) <<? List(("limit", "5"))
val response = makeGetRequest(request)
Then("We should get a successful response")
@ -85,10 +87,10 @@ class LogCacheEndpointTest extends V510ServerSetup {
feature(s"test $ApiEndpoint1 version $VersionOfApi - Authorized access with offset parameter") {
scenario("We get log cache with offset parameter only", ApiEndpoint1, VersionOfApi) {
Given("We have a user with proper entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAllLevelLogsAtAllBanks.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLogCacheAll.toString)
When("We make a request with offset parameter")
val request = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1) <<? List(("offset", "2"))
val request = (v5_1_0_Request / "system" / "log-cache" / "info").GET <@(user1) <<? List(("offset", "2"))
val response = makeGetRequest(request)
Then("We should get a successful response")
@ -103,10 +105,10 @@ class LogCacheEndpointTest extends V510ServerSetup {
feature(s"test $ApiEndpoint1 version $VersionOfApi - Authorized access with both parameters") {
scenario("We get log cache with both limit and offset parameters", ApiEndpoint1, VersionOfApi) {
Given("We have a user with proper entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAllLevelLogsAtAllBanks.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLogCacheAll.toString)
When("We make a request with both limit and offset parameters")
val request = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1) <<? List(("limit", "3"), ("offset", "1"))
val request = (v5_1_0_Request / "system" / "log-cache" / "info").GET <@(user1) <<? List(("limit", "3"), ("offset", "1"))
val response = makeGetRequest(request)
Then("We should get a successful response")
@ -122,13 +124,13 @@ class LogCacheEndpointTest extends V510ServerSetup {
feature(s"test $ApiEndpoint1 version $VersionOfApi - Edge cases") {
scenario("We get error with zero limit (invalid parameter)", ApiEndpoint1, VersionOfApi) {
Given("We have a user with proper entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAllLevelLogsAtAllBanks.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLogCacheAll.toString)
When("We make a request with zero limit")
val request = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1) <<? List(("limit", "0"))
val request = (v5_1_0_Request / "system" / "log-cache" / "info").GET <@(user1) <<? List(("limit", "0"))
val response = makeGetRequest(request)
Then("We should get a bad request response")
Then("We should get a not found response since endpoint does not exist")
response.code should equal(400)
val json = response.body.extract[JObject]
@ -139,10 +141,10 @@ class LogCacheEndpointTest extends V510ServerSetup {
scenario("We get log cache with large offset", ApiEndpoint1, VersionOfApi) {
Given("We have a user with proper entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAllLevelLogsAtAllBanks.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLogCacheAll.toString)
When("We make a request with very large offset")
val request = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1) <<? List(("offset", "10000"))
val request = (v5_1_0_Request / "system" / "log-cache" / "info").GET <@(user1) <<? List(("offset", "10000"))
val response = makeGetRequest(request)
Then("We should get a successful response")
@ -156,10 +158,10 @@ class LogCacheEndpointTest extends V510ServerSetup {
scenario("We get log cache with minimum valid limit", ApiEndpoint1, VersionOfApi) {
Given("We have a user with proper entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAllLevelLogsAtAllBanks.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLogCacheAll.toString)
When("We make a request with minimum valid limit (1)")
val request = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1) <<? List(("limit", "1"))
val request = (v5_1_0_Request / "system" / "log-cache" / "info").GET <@(user1) <<? List(("limit", "1"))
val response = makeGetRequest(request)
Then("We should get a successful response")
@ -175,10 +177,10 @@ class LogCacheEndpointTest extends V510ServerSetup {
feature(s"test $ApiEndpoint1 version $VersionOfApi - Different log levels") {
scenario("We test different log levels with pagination", ApiEndpoint1, VersionOfApi) {
Given("We have a user with proper entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAllLevelLogsAtAllBanks.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLogCacheAll.toString)
When("We make requests to different log levels with pagination")
val logLevels = List("DEBUG", "INFO", "WARN", "ERROR", "ALL")
val logLevels = List("debug", "info", "warning", "error", "all")
logLevels.foreach { logLevel =>
val request = (v5_1_0_Request / "system" / "log-cache" / logLevel).GET <@(user1) <<? List(("limit", "2"), ("offset", "0"))
@ -197,48 +199,48 @@ class LogCacheEndpointTest extends V510ServerSetup {
feature(s"test $ApiEndpoint1 version $VersionOfApi - Invalid log level") {
scenario("We get error for invalid log level", ApiEndpoint1, VersionOfApi) {
Given("We have a user with proper entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAllLevelLogsAtAllBanks.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLogCacheAll.toString)
When("We make a request with invalid log level")
val request = (v5_1_0_Request / "system" / "log-cache" / "INVALID_LEVEL").GET <@(user1)
val request = (v5_1_0_Request / "system" / "log-cache" / "invalid_level").GET <@(user1)
val response = makeGetRequest(request)
Then("We should get a bad request response")
response.code should equal(400)
Then("We should get a not found response since endpoint does not exist")
response.code should equal(404)
}
}
feature(s"test $ApiEndpoint1 version $VersionOfApi - Invalid parameters") {
scenario("We test invalid pagination parameters", ApiEndpoint1, VersionOfApi) {
Given("We have a user with proper entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAllLevelLogsAtAllBanks.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLogCacheAll.toString)
When("We test with non-numeric limit parameter")
val requestInvalidLimit = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1) <<? List(("limit", "abc"))
val requestInvalidLimit = (v5_1_0_Request / "system" / "log-cache" / "info").GET <@(user1) <<? List(("limit", "abc"))
val responseInvalidLimit = makeGetRequest(requestInvalidLimit)
Then("We should get a bad request response")
Then("We should get a not found response since endpoint does not exist")
responseInvalidLimit.code should equal(400)
When("We test with non-numeric offset parameter")
val requestInvalidOffset = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1) <<? List(("offset", "xyz"))
val requestInvalidOffset = (v5_1_0_Request / "system" / "log-cache" / "info").GET <@(user1) <<? List(("offset", "xyz"))
val responseInvalidOffset = makeGetRequest(requestInvalidOffset)
Then("We should get a bad request response")
Then("We should get a not found response since endpoint does not exist")
responseInvalidOffset.code should equal(400)
When("We test with negative limit parameter")
val requestNegativeLimit = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1) <<? List(("limit", "-1"))
val requestNegativeLimit = (v5_1_0_Request / "system" / "log-cache" / "info").GET <@(user1) <<? List(("limit", "-1"))
val responseNegativeLimit = makeGetRequest(requestNegativeLimit)
Then("We should get a bad request response")
Then("We should get a not found response since endpoint does not exist")
responseNegativeLimit.code should equal(400)
When("We test with negative offset parameter")
val requestNegativeOffset = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1) <<? List(("offset", "-1"))
val requestNegativeOffset = (v5_1_0_Request / "system" / "log-cache" / "info").GET <@(user1) <<? List(("offset", "-1"))
val responseNegativeOffset = makeGetRequest(requestNegativeOffset)
Then("We should get a bad request response")
Then("We should get a not found response since endpoint does not exist")
responseNegativeOffset.code should equal(400)
}
}

View File

@ -0,0 +1,360 @@
/**
Open Bank Project - API
Copyright (C) 2011-2024, TESOBE GmbH
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Email: contact@tesobe.com
TESOBE GmbH
Osloerstrasse 16/17
Berlin 13359, Germany
This product includes software developed at
TESOBE (http://www.tesobe.com/)
*/
package code.api.v6_0_0
import code.api.util.APIUtil.OAuth._
import code.api.util.ApiRole.{CanGetCacheConfig, CanGetCacheInfo, CanInvalidateCacheNamespace}
import code.api.util.ErrorMessages.{InvalidJsonFormat, UserHasMissingRoles, UserNotLoggedIn}
import code.api.v6_0_0.OBPAPI6_0_0.Implementations6_0_0
import code.entitlement.Entitlement
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.model.ErrorMessage
import com.openbankproject.commons.util.ApiVersion
import net.liftweb.json.Serialization.write
import org.scalatest.Tag
class CacheEndpointsTest extends V600ServerSetup {
/**
* Test tags
* Example: To run tests with tag "getCacheConfig":
* mvn test -D tagsToInclude
*
* This is made possible by the scalatest maven plugin
*/
object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString)
object ApiEndpoint1 extends Tag(nameOf(Implementations6_0_0.getCacheConfig))
object ApiEndpoint2 extends Tag(nameOf(Implementations6_0_0.getCacheInfo))
object ApiEndpoint3 extends Tag(nameOf(Implementations6_0_0.invalidateCacheNamespace))
// ============================================================================================================
// GET /system/cache/config - Get Cache Configuration
// ============================================================================================================
feature(s"test $ApiEndpoint1 version $VersionOfApi - Unauthorized access") {
scenario("We call getCacheConfig without user credentials", ApiEndpoint1, VersionOfApi) {
When("We make a request v6.0.0 without credentials")
val request = (v6_0_0_Request / "system" / "cache" / "config").GET
val response = makeGetRequest(request)
Then("We should get a 401")
response.code should equal(401)
response.body.extract[ErrorMessage].message should equal(UserNotLoggedIn)
}
}
feature(s"test $ApiEndpoint1 version $VersionOfApi - Missing role") {
scenario("We call getCacheConfig without the CanGetCacheConfig role", ApiEndpoint1, VersionOfApi) {
When("We make a request v6.0.0 without the required role")
val request = (v6_0_0_Request / "system" / "cache" / "config").GET <@ (user1)
val response = makeGetRequest(request)
Then("We should get a 403")
response.code should equal(403)
And("error should be " + UserHasMissingRoles + CanGetCacheConfig)
response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanGetCacheConfig)
}
}
feature(s"test $ApiEndpoint1 version $VersionOfApi - Authorized access") {
scenario("We call getCacheConfig with the CanGetCacheConfig role", ApiEndpoint1, VersionOfApi) {
Given("We have a user with CanGetCacheConfig entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetCacheConfig.toString)
When("We make a request v6.0.0 with proper role")
val request = (v6_0_0_Request / "system" / "cache" / "config").GET <@ (user1)
val response = makeGetRequest(request)
Then("We should get a 200")
response.code should equal(200)
And("The response should have the correct structure")
val cacheConfig = response.body.extract[CacheConfigJsonV600]
cacheConfig.instance_id should not be empty
cacheConfig.environment should not be empty
cacheConfig.global_prefix should not be empty
And("Redis status should have valid data")
cacheConfig.redis_status.available shouldBe a[Boolean]
cacheConfig.redis_status.url should not be empty
cacheConfig.redis_status.port should be > 0
cacheConfig.redis_status.use_ssl shouldBe a[Boolean]
And("In-memory status should have valid data")
cacheConfig.in_memory_status.available shouldBe a[Boolean]
cacheConfig.in_memory_status.current_size should be >= 0L
}
}
// ============================================================================================================
// GET /system/cache/info - Get Cache Information
// ============================================================================================================
feature(s"test $ApiEndpoint2 version $VersionOfApi - Unauthorized access") {
scenario("We call getCacheInfo without user credentials", ApiEndpoint2, VersionOfApi) {
When("We make a request v6.0.0 without credentials")
val request = (v6_0_0_Request / "system" / "cache" / "info").GET
val response = makeGetRequest(request)
Then("We should get a 401")
response.code should equal(401)
response.body.extract[ErrorMessage].message should equal(UserNotLoggedIn)
}
}
feature(s"test $ApiEndpoint2 version $VersionOfApi - Missing role") {
scenario("We call getCacheInfo without the CanGetCacheInfo role", ApiEndpoint2, VersionOfApi) {
When("We make a request v6.0.0 without the required role")
val request = (v6_0_0_Request / "system" / "cache" / "info").GET <@ (user1)
val response = makeGetRequest(request)
Then("We should get a 403")
response.code should equal(403)
And("error should be " + UserHasMissingRoles + CanGetCacheInfo)
response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanGetCacheInfo)
}
}
feature(s"test $ApiEndpoint2 version $VersionOfApi - Authorized access") {
scenario("We call getCacheInfo with the CanGetCacheInfo role", ApiEndpoint2, VersionOfApi) {
Given("We have a user with CanGetCacheInfo entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetCacheInfo.toString)
When("We make a request v6.0.0 with proper role")
val request = (v6_0_0_Request / "system" / "cache" / "info").GET <@ (user1)
val response = makeGetRequest(request)
Then("We should get a 200")
response.code should equal(200)
And("The response should have the correct structure")
val cacheInfo = response.body.extract[CacheInfoJsonV600]
cacheInfo.namespaces should not be null
cacheInfo.total_keys should be >= 0
cacheInfo.redis_available shouldBe a[Boolean]
And("Each namespace should have valid data")
cacheInfo.namespaces.foreach { namespace =>
namespace.namespace_id should not be empty
namespace.prefix should not be empty
namespace.current_version should be > 0L
namespace.key_count should be >= 0
namespace.description should not be empty
namespace.category should not be empty
namespace.storage_location should not be empty
namespace.storage_location should (equal("redis") or equal("memory") or equal("both") or equal("unknown"))
namespace.ttl_info should not be empty
namespace.ttl_info shouldBe a[String]
}
}
}
// ============================================================================================================
// POST /management/cache/namespaces/invalidate - Invalidate Cache Namespace
// ============================================================================================================
feature(s"test $ApiEndpoint3 version $VersionOfApi - Unauthorized access") {
scenario("We call invalidateCacheNamespace without user credentials", ApiEndpoint3, VersionOfApi) {
When("We make a request v6.0.0 without credentials")
val request = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST
val response = makePostRequest(request, write(InvalidateCacheNamespaceJsonV600("rd_localised")))
Then("We should get a 401")
response.code should equal(401)
response.body.extract[ErrorMessage].message should equal(UserNotLoggedIn)
}
}
feature(s"test $ApiEndpoint3 version $VersionOfApi - Missing role") {
scenario("We call invalidateCacheNamespace without the CanInvalidateCacheNamespace role", ApiEndpoint3, VersionOfApi) {
When("We make a request v6.0.0 without the required role")
val request = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1)
val response = makePostRequest(request, write(InvalidateCacheNamespaceJsonV600("rd_localised")))
Then("We should get a 403")
response.code should equal(403)
And("error should be " + UserHasMissingRoles + CanInvalidateCacheNamespace)
response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanInvalidateCacheNamespace)
}
}
feature(s"test $ApiEndpoint3 version $VersionOfApi - Invalid JSON format") {
scenario("We call invalidateCacheNamespace with invalid JSON", ApiEndpoint3, VersionOfApi) {
Given("We have a user with CanInvalidateCacheNamespace entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanInvalidateCacheNamespace.toString)
When("We make a request with invalid JSON")
val request = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1)
val response = makePostRequest(request, """{"invalid": "json"}""")
Then("We should get a 400")
response.code should equal(400)
And("error should be InvalidJsonFormat")
response.body.extract[ErrorMessage].message should startWith(InvalidJsonFormat)
}
}
feature(s"test $ApiEndpoint3 version $VersionOfApi - Invalid namespace_id") {
scenario("We call invalidateCacheNamespace with non-existent namespace_id", ApiEndpoint3, VersionOfApi) {
Given("We have a user with CanInvalidateCacheNamespace entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanInvalidateCacheNamespace.toString)
When("We make a request with invalid namespace_id")
val request = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1)
val response = makePostRequest(request, write(InvalidateCacheNamespaceJsonV600("invalid_namespace")))
Then("We should get a 400")
response.code should equal(400)
And("error should mention invalid namespace_id")
val errorMessage = response.body.extract[ErrorMessage].message
errorMessage should include("Invalid namespace_id")
errorMessage should include("invalid_namespace")
}
}
feature(s"test $ApiEndpoint3 version $VersionOfApi - Authorized access with valid namespace") {
scenario("We call invalidateCacheNamespace with valid rd_localised namespace", ApiEndpoint3, VersionOfApi) {
Given("We have a user with CanInvalidateCacheNamespace entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanInvalidateCacheNamespace.toString)
When("We make a request with valid namespace_id")
val request = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1)
val response = makePostRequest(request, write(InvalidateCacheNamespaceJsonV600("rd_localised")))
Then("We should get a 200")
response.code should equal(200)
And("The response should have the correct structure")
val result = response.body.extract[InvalidatedCacheNamespaceJsonV600]
result.namespace_id should equal("rd_localised")
result.old_version should be > 0L
result.new_version should be > result.old_version
result.new_version should equal(result.old_version + 1)
result.status should equal("invalidated")
}
scenario("We call invalidateCacheNamespace with valid connector namespace", ApiEndpoint3, VersionOfApi) {
Given("We have a user with CanInvalidateCacheNamespace entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanInvalidateCacheNamespace.toString)
When("We make a request with connector namespace_id")
val request = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1)
val response = makePostRequest(request, write(InvalidateCacheNamespaceJsonV600("connector")))
Then("We should get a 200")
response.code should equal(200)
And("The response should have the correct structure")
val result = response.body.extract[InvalidatedCacheNamespaceJsonV600]
result.namespace_id should equal("connector")
result.old_version should be > 0L
result.new_version should be > result.old_version
result.status should equal("invalidated")
}
scenario("We call invalidateCacheNamespace with valid abac_rule namespace", ApiEndpoint3, VersionOfApi) {
Given("We have a user with CanInvalidateCacheNamespace entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanInvalidateCacheNamespace.toString)
When("We make a request with abac_rule namespace_id")
val request = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1)
val response = makePostRequest(request, write(InvalidateCacheNamespaceJsonV600("abac_rule")))
Then("We should get a 200")
response.code should equal(200)
And("The response should have the correct structure")
val result = response.body.extract[InvalidatedCacheNamespaceJsonV600]
result.namespace_id should equal("abac_rule")
result.status should equal("invalidated")
}
}
feature(s"test $ApiEndpoint3 version $VersionOfApi - Version increment validation") {
scenario("We verify that cache version increments correctly on multiple invalidations", ApiEndpoint3, VersionOfApi) {
Given("We have a user with CanInvalidateCacheNamespace entitlement")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanInvalidateCacheNamespace.toString)
When("We invalidate the same namespace twice")
val request1 = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1)
val response1 = makePostRequest(request1, write(InvalidateCacheNamespaceJsonV600("rd_dynamic")))
Then("First invalidation should succeed")
response1.code should equal(200)
val result1 = response1.body.extract[InvalidatedCacheNamespaceJsonV600]
val firstNewVersion = result1.new_version
When("We invalidate again")
val request2 = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1)
val response2 = makePostRequest(request2, write(InvalidateCacheNamespaceJsonV600("rd_dynamic")))
Then("Second invalidation should succeed")
response2.code should equal(200)
val result2 = response2.body.extract[InvalidatedCacheNamespaceJsonV600]
And("Version should have incremented again")
result2.old_version should equal(firstNewVersion)
result2.new_version should equal(firstNewVersion + 1)
result2.status should equal("invalidated")
}
}
// ============================================================================================================
// Cross-endpoint test - Verify cache info updates after invalidation
// ============================================================================================================
feature(s"Integration test - Cache endpoints interaction") {
scenario("We verify cache info shows updated version after invalidation", ApiEndpoint2, ApiEndpoint3, VersionOfApi) {
Given("We have a user with both CanGetCacheInfo and CanInvalidateCacheNamespace entitlements")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetCacheInfo.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanInvalidateCacheNamespace.toString)
When("We get the initial cache info")
val getRequest1 = (v6_0_0_Request / "system" / "cache" / "info").GET <@ (user1)
val getResponse1 = makeGetRequest(getRequest1)
getResponse1.code should equal(200)
val cacheInfo1 = getResponse1.body.extract[CacheInfoJsonV600]
// Find the rd_static namespace (or any other valid namespace)
val targetNamespace = "rd_static"
val initialVersion = cacheInfo1.namespaces.find(_.namespace_id == targetNamespace).map(_.current_version)
When("We invalidate the namespace")
val invalidateRequest = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1)
val invalidateResponse = makePostRequest(invalidateRequest, write(InvalidateCacheNamespaceJsonV600(targetNamespace)))
invalidateResponse.code should equal(200)
val invalidateResult = invalidateResponse.body.extract[InvalidatedCacheNamespaceJsonV600]
When("We get the cache info again")
val getRequest2 = (v6_0_0_Request / "system" / "cache" / "info").GET <@ (user1)
val getResponse2 = makeGetRequest(getRequest2)
getResponse2.code should equal(200)
val cacheInfo2 = getResponse2.body.extract[CacheInfoJsonV600]
Then("The namespace version should have been incremented")
val updatedNamespace = cacheInfo2.namespaces.find(_.namespace_id == targetNamespace)
updatedNamespace should not be None
if (initialVersion.isDefined) {
updatedNamespace.get.current_version should be > initialVersion.get
}
updatedNamespace.get.current_version should equal(invalidateResult.new_version)
}
}
}