From 9844051a8522a9801eda0e3a9cec36fbbfc52179 Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 6 Jan 2026 12:16:57 +0100 Subject: [PATCH 01/14] docfix/tweaked the default port for http4s --- README.md | 2 +- obp-api/src/main/resources/props/sample.props.template | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6d92e9c2b..33e7df4c4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 29e80e27b..d181a5a1f 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -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 \ No newline at end of file From c99cb73cfdfb0737de1040396f232be74c082f7f Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 6 Jan 2026 12:20:39 +0100 Subject: [PATCH 02/14] refactor/code clean --- obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala index 8207e7268..7a2a42c1c 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala @@ -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 From 2e46a93ae351ec621ad130e88d13e250610e4dbd Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 1 Jan 2026 03:22:28 +0100 Subject: [PATCH 03/14] Tests for cache info etc --- .../code/api/v6_0_0/CacheEndpointsTest.scala | 353 ++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala diff --git a/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala new file mode 100644 index 000000000..8b40957db --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala @@ -0,0 +1,353 @@ +/** +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 . + +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.providers should not be empty + cacheConfig.instance_id should not be empty + cacheConfig.environment should not be empty + cacheConfig.global_prefix should not be empty + + And("Providers should have valid data") + cacheConfig.providers.foreach { provider => + provider.provider should not be empty + provider.enabled shouldBe a[Boolean] + } + } + } + + // ============================================================================================================ + // 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 + } + } + } + + // ============================================================================================================ + // 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) + } + } +} From 31a277dace8163721c90625089125a29c24b3827 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 1 Jan 2026 03:40:41 +0100 Subject: [PATCH 04/14] Cache info storage_location --- .../main/scala/code/api/cache/InMemory.scala | 18 ++++++++ .../scala/code/api/v6_0_0/APIMethods600.scala | 11 ++++- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 45 +++++++++++++++---- .../code/api/v6_0_0/CacheEndpointsTest.scala | 2 + 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/obp-api/src/main/scala/code/api/cache/InMemory.scala b/obp-api/src/main/scala/code/api/cache/InMemory.scala index ba86bbfa6..9c4054430 100644 --- a/obp-api/src/main/scala/code/api/cache/InMemory.scala +++ b/obp-api/src/main/scala/code/api/cache/InMemory.scala @@ -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 + } + } } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 70a3f3565..02c824ce0 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -733,6 +733,11 @@ 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 |- Total key count across all namespaces |- Redis availability status | @@ -749,7 +754,8 @@ trait APIMethods600 { current_version = 1, key_count = 42, description = "Rate limit call counters", - category = "Rate Limiting" + category = "Rate Limiting", + storage_location = "redis" ), CacheNamespaceInfoJsonV600( namespace_id = "rd_localised", @@ -757,7 +763,8 @@ trait APIMethods600 { current_version = 1, key_count = 128, description = "Localized resource docs", - category = "API Documentation" + category = "API Documentation", + storage_location = "redis" ) ), total_keys = 170, diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index ae8587f8b..2a29d7a96 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -289,7 +289,8 @@ case class CacheNamespaceInfoJsonV600( current_version: Long, key_count: Int, description: String, - category: String + category: String, + storage_location: String ) case class CacheInfoJsonV600( @@ -1153,7 +1154,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { } def createCacheInfoJsonV600(): CacheInfoJsonV600 = { - import code.api.cache.Redis + import code.api.cache.{Redis, InMemory} import code.api.Constant val namespaceDescriptions = Map( @@ -1178,14 +1179,41 @@ 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" + + try { + redisKeyCount = Redis.countKeys(pattern) + totalKeys += redisKeyCount } catch { case _: Throwable => redisAvailable = false - 0 + } + + try { + memoryKeyCount = InMemory.countKeys(pattern) + totalKeys += memoryKeyCount + } 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" + 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 +1224,8 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { current_version = version, key_count = keyCount, description = description, - category = category + category = category, + storage_location = storageLocation ) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala index 8b40957db..ee8460f73 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala @@ -156,6 +156,8 @@ class CacheEndpointsTest extends V600ServerSetup { 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")) } } } From f94a9cf73fa4f9f1fb07c4d01043a06672120f7a Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 1 Jan 2026 04:34:55 +0100 Subject: [PATCH 05/14] System Cache Config fields --- .../scala/code/api/v6_0_0/APIMethods600.scala | 37 ++++---- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 93 ++++++++++++++----- .../code/api/v6_0_0/CacheEndpointsTest.scala | 2 + 3 files changed, 89 insertions(+), 43 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 02c824ce0..69190fb78 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -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", @@ -738,6 +732,9 @@ trait APIMethods600 { | - "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 | @@ -755,7 +752,8 @@ trait APIMethods600 { key_count = 42, description = "Rate limit call counters", category = "Rate Limiting", - storage_location = "redis" + storage_location = "redis", + ttl_info = "range 60s to 86400s (avg 3600s)" ), CacheNamespaceInfoJsonV600( namespace_id = "rd_localised", @@ -764,7 +762,8 @@ trait APIMethods600 { key_count = 128, description = "Localized resource docs", category = "API Documentation", - storage_location = "redis" + storage_location = "redis", + ttl_info = "3600s" ) ), total_keys = 170, diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 2a29d7a96..36ab2d96b 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -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 @@ -290,7 +295,8 @@ case class CacheNamespaceInfoJsonV600( key_count: Int, description: String, category: String, - storage_location: String + storage_location: String, + ttl_info: String ) case class CacheInfoJsonV600( @@ -1120,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 { @@ -1145,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 @@ -1156,6 +1171,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { def createCacheInfoJsonV600(): CacheInfoJsonV600 = { 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"), @@ -1183,10 +1199,33 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { 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 @@ -1195,6 +1234,10 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { 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) @@ -1203,6 +1246,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { // 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" @@ -1225,7 +1269,8 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { key_count = keyCount, description = description, category = category, - storage_location = storageLocation + storage_location = storageLocation, + ttl_info = ttlInfo ) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala index ee8460f73..0b181a03f 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala @@ -158,6 +158,8 @@ class CacheEndpointsTest extends V600ServerSetup { 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] } } } From 5c2f6b6fdc49b395c5c413a559ad921dfb65f1a7 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 1 Jan 2026 04:36:28 +0100 Subject: [PATCH 06/14] System Cache Config fields fix --- obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 69190fb78..3f8a0d05e 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -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 From 275baf624456e681203e42dae7f4f47420daf5ce Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 1 Jan 2026 04:40:39 +0100 Subject: [PATCH 07/14] System Cache Config fields fix tests --- .../code/api/v6_0_0/CacheEndpointsTest.scala | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala index 0b181a03f..7dd6022da 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala @@ -90,16 +90,19 @@ class CacheEndpointsTest extends V600ServerSetup { And("The response should have the correct structure") val cacheConfig = response.body.extract[CacheConfigJsonV600] - cacheConfig.providers should not be empty cacheConfig.instance_id should not be empty cacheConfig.environment should not be empty cacheConfig.global_prefix should not be empty - And("Providers should have valid data") - cacheConfig.providers.foreach { provider => - provider.provider should not be empty - provider.enabled shouldBe a[Boolean] - } + 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 } } From bb5c413aaa53df6ad2bbb76c45a20064b47a2f3e Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 4 Jan 2026 20:23:19 +0100 Subject: [PATCH 08/14] bugfix: support multiple oauth2.jwk_set.url --- obp-api/src/main/scala/code/api/OAuth2.scala | 106 +++++++++++------- .../scala/code/api/util/ErrorMessages.scala | 2 +- 2 files changed, 68 insertions(+), 40 deletions(-) diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala index 00800f99f..9ba607c7b 100644 --- a/obp-api/src/main/scala/code/api/OAuth2.scala +++ b/obp-api/src/main/scala/code/api/OAuth2.scala @@ -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 diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index acaad26de..4c17086c1 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -269,7 +269,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. " From 1542593e0ef8c74b6130f809a1ba835ef3640f94 Mon Sep 17 00:00:00 2001 From: tesobe-daniel Date: Mon, 5 Jan 2026 13:35:10 +0100 Subject: [PATCH 09/14] Ignore GitHub directory in .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8b845ec5b..c057cc52c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.github/* *.class *.db .DS_Store From aa823e1ee32da119d29c8c88500a8954cc619d51 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 5 Jan 2026 17:40:54 +0100 Subject: [PATCH 10/14] Resource doc yaml respects content parameter --- .../api/ResourceDocs1_4_0/ResourceDocs140.scala | 15 ++++++++++----- .../ResourceDocsAPIMethods.scala | 6 +++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala index 3845c33ea..70f4b4cd5 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala @@ -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 diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala index 080173b5f..f7ef88d26 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala @@ -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], From 415e22f5a217f0aae8ea1656bc2ccbd6f4ab9bb9 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 5 Jan 2026 23:47:21 +0100 Subject: [PATCH 11/14] Log cache separate endpoints and different Role names --- .../scala/code/api/cache/RedisLogger.scala | 12 +- .../main/scala/code/api/util/ApiRole.scala | 36 +-- .../scala/code/api/v5_1_0/APIMethods510.scala | 213 +++++++++++++++--- .../api/v5_1_0/LogCacheEndpointTest.scala | 74 +++--- 4 files changed, 236 insertions(+), 99 deletions(-) diff --git a/obp-api/src/main/scala/code/api/cache/RedisLogger.scala b/obp-api/src/main/scala/code/api/cache/RedisLogger.scala index ee9b58c3a..02db0209c 100644 --- a/obp-api/src/main/scala/code/api/cache/RedisLogger.scala +++ b/obp-api/src/main/scala/code/api/cache/RedisLogger.scala @@ -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) } } diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index c025fe7c2..9c7a990be 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -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() diff --git a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index 7357f474f..8f8968e45 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala @@ -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))) + 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 :: 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 :: 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 :: 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 :: 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 :: 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 } diff --git a/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala index 70f1a8e56..690464e06 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala @@ -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 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,21 @@ 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 should be(UserHasMissingRoles + CanGetSystemLogCacheAll) } } 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 +66,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) < val request = (v5_1_0_Request / "system" / "log-cache" / logLevel).GET <@(user1) < Date: Mon, 5 Jan 2026 23:49:39 +0100 Subject: [PATCH 12/14] Add apiTagLogCache tag to log cache endpoints --- obp-api/src/main/scala/code/api/util/ApiTag.scala | 1 + .../main/scala/code/api/v5_1_0/APIMethods510.scala | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/ApiTag.scala b/obp-api/src/main/scala/code/api/util/ApiTag.scala index 91b4f3eb9..bd4c41f01 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -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") diff --git a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index 8f8968e45..e3f26b02f 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala @@ -270,7 +270,7 @@ trait APIMethods510 { EmptyBody, EmptyBody, List($UserNotLoggedIn, UnknownError), - apiTagSystem :: apiTagApi :: Nil, + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheTrace, canGetSystemLogCacheAll))) lazy val logCacheTraceEndpoint: OBPEndpoint = { @@ -301,7 +301,7 @@ trait APIMethods510 { EmptyBody, EmptyBody, List($UserNotLoggedIn, UnknownError), - apiTagSystem :: apiTagApi :: Nil, + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheDebug, canGetSystemLogCacheAll))) lazy val logCacheDebugEndpoint: OBPEndpoint = { @@ -332,7 +332,7 @@ trait APIMethods510 { EmptyBody, EmptyBody, List($UserNotLoggedIn, UnknownError), - apiTagSystem :: apiTagApi :: Nil, + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheInfo, canGetSystemLogCacheAll))) lazy val logCacheInfoEndpoint: OBPEndpoint = { @@ -363,7 +363,7 @@ trait APIMethods510 { EmptyBody, EmptyBody, List($UserNotLoggedIn, UnknownError), - apiTagSystem :: apiTagApi :: Nil, + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheWarning, canGetSystemLogCacheAll))) lazy val logCacheWarningEndpoint: OBPEndpoint = { @@ -394,7 +394,7 @@ trait APIMethods510 { EmptyBody, EmptyBody, List($UserNotLoggedIn, UnknownError), - apiTagSystem :: apiTagApi :: Nil, + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheError, canGetSystemLogCacheAll))) lazy val logCacheErrorEndpoint: OBPEndpoint = { @@ -425,7 +425,7 @@ trait APIMethods510 { EmptyBody, EmptyBody, List($UserNotLoggedIn, UnknownError), - apiTagSystem :: apiTagApi :: Nil, + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheAll))) lazy val logCacheAllEndpoint: OBPEndpoint = { From e545069bbf20007f3020bfcc626785e65efc4767 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 6 Jan 2026 15:47:38 +0100 Subject: [PATCH 13/14] flushall build and run runs http4s as well --- flushall_build_and_run.sh | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/flushall_build_and_run.sh b/flushall_build_and_run.sh index 833442508..6708a9ed1 100755 --- a/flushall_build_and_run.sh +++ b/flushall_build_and_run.sh @@ -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 From 9e9abdf16ad83e824cdb9517a3fb94d829410b30 Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 6 Jan 2026 16:54:07 +0100 Subject: [PATCH 14/14] test/fixed failed tests --- obp-api/src/main/scala/code/api/util/ErrorMessages.scala | 1 + .../src/main/scala/code/api/v6_0_0/APIMethods600.scala | 2 +- .../test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala | 8 +++++--- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 4c17086c1..0a5110ebe 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -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." diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 3f8a0d05e..b5b2c15b3 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -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)) diff --git a/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala index 690464e06..4a446b032 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala @@ -1,7 +1,7 @@ package code.api.v5_1_0 import code.api.util.APIUtil.OAuth._ -import code.api.util.ApiRole.CanGetSystemLogCacheAll +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 @@ -41,7 +41,9 @@ class LogCacheEndpointTest extends V510ServerSetup { val response = makeGetRequest(request) Then("error should be " + UserHasMissingRoles + CanGetSystemLogCacheAll) response.code should equal(403) - response.body.extract[ErrorMessage].message should be(UserHasMissingRoles + CanGetSystemLogCacheAll) + 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) } } @@ -129,7 +131,7 @@ class LogCacheEndpointTest extends V510ServerSetup { val response = makeGetRequest(request) Then("We should get a not found response since endpoint does not exist") - response.code should equal(404) + response.code should equal(400) val json = response.body.extract[JObject] And("The response should contain the correct error message")