From b4856ef2acb5fadd65bb158c3ec4211fef2f9193 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 22 Jan 2026 15:09:16 +0100 Subject: [PATCH 1/5] docfix: fixing dynamic-entity example response for list --- .../entity/helper/DynamicEntityHelper.scala | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala index 34f5d31685..9a5414d4ad 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala @@ -581,11 +581,16 @@ case class DynamicEntityInfo(definition: String, entityName: String, bankId: Opt (singleName -> (JObject(JField(idName, JString(ExampleValue.idExample.value)) :: getSingleExampleWithoutId.obj))) } - def getExampleList: JObject = if (bankId.isDefined){ - val objectList: JObject = (listName -> JArray(List(getSingleExample))) - bankIdJObject merge objectList - } else{ - (listName -> JArray(List(getSingleExample))) + def getExampleList: JObject = { + // Create the list item without the singleName wrapper - the actual API response + // returns a flat list of objects, not wrapped in entity name + val listItem: JObject = JObject(JField(idName, JString(ExampleValue.idExample.value)) :: getSingleExampleWithoutId.obj) + if (bankId.isDefined) { + val objectList: JObject = (listName -> JArray(List(listItem))) + bankIdJObject merge objectList + } else { + (listName -> JArray(List(listItem))) + } } val canCreateRole: ApiRole = DynamicEntityInfo.canCreateRole(entityName, bankId) From 3d4660ec0b26448ea0c5cefd2f1031a980604429 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 22 Jan 2026 18:37:42 +0100 Subject: [PATCH 2/5] added _links for dynamic entity CRUD endpoints. + adding GET system database pool endpoint --- .../main/scala/code/api/util/ApiRole.scala | 3 + .../scala/code/api/v6_0_0/APIMethods600.scala | 80 ++++++++++++++++++- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 67 +++++++++++++++- 3 files changed, 145 insertions(+), 5 deletions(-) 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 9e1f404b7a..2140d58b60 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -406,6 +406,9 @@ object ApiRole extends MdcLoggable{ case class CanGetCacheInfo(requiresBankId: Boolean = false) extends ApiRole lazy val canGetCacheInfo = CanGetCacheInfo() + case class CanGetDatabasePoolInfo(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetDatabasePoolInfo = CanGetDatabasePoolInfo() + case class CanGetCacheNamespaces(requiresBankId: Boolean = false) extends ApiRole lazy val canGetCacheNamespaces = CanGetCacheNamespaces() 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 6f436a0233..1ffe22eefd 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 @@ -30,7 +30,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, CreateAbacRuleJsonV600, CreateDynamicEntityRequestJsonV600, CurrentConsumerJsonV600, DynamicEntityDefinitionJsonV600, DynamicEntityDefinitionWithCountJsonV600, DynamicEntitiesWithCountJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, MyDynamicEntitiesJsonV600, RedisCacheStatusJsonV600, UpdateAbacRuleJsonV600, UpdateDynamicEntityRequestJsonV600} +import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CreateAbacRuleJsonV600, CreateDynamicEntityRequestJsonV600, CurrentConsumerJsonV600, DynamicEntityDefinitionJsonV600, DynamicEntityDefinitionWithCountJsonV600, DynamicEntitiesWithCountJsonV600, DynamicEntityLinksJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, MyDynamicEntitiesJsonV600, RedisCacheStatusJsonV600, RelatedLinkJsonV600, UpdateAbacRuleJsonV600, UpdateDynamicEntityRequestJsonV600} import code.api.v6_0_0.OBPAPI6_0_0 import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} import code.metrics.APIMetrics @@ -795,6 +795,62 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + getDatabasePoolInfo, + implementedInApiVersion, + nameOf(getDatabasePoolInfo), + "GET", + "/system/database/pool", + "Get Database Pool Information", + """Returns HikariCP connection pool information including: + | + |- Pool name + |- Active connections: currently in use + |- Idle connections: available in pool + |- Total connections: active + idle + |- Threads awaiting connection: requests waiting for a connection + |- Configuration: max pool size, min idle, timeouts + | + |This helps diagnose connection pool issues such as connection leaks or pool exhaustion. + | + |Authentication is Required + |""", + EmptyBody, + DatabasePoolInfoJsonV600( + pool_name = "HikariPool-1", + active_connections = 5, + idle_connections = 3, + total_connections = 8, + threads_awaiting_connection = 0, + maximum_pool_size = 10, + minimum_idle = 2, + connection_timeout_ms = 30000, + idle_timeout_ms = 600000, + max_lifetime_ms = 1800000, + keepalive_time_ms = 0 + ), + List( + AuthenticatedUserIsRequired, + UserHasMissingRoles, + UnknownError + ), + List(apiTagSystem, apiTagApi), + Some(List(canGetDatabasePoolInfo)) + ) + + lazy val getDatabasePoolInfo: OBPEndpoint = { + case "system" :: "database" :: "pool" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", u.userId, canGetDatabasePoolInfo, callContext) + } yield { + val result = JSONFactory600.createDatabasePoolInfoJsonV600() + (result, HttpCode.`200`(callContext)) + } + } + } + lazy val getCurrentConsumer: OBPEndpoint = { case "consumers" :: "current" :: Nil JsonGet _ => { cc => { @@ -6924,7 +6980,16 @@ trait APIMethods600 { user_id = "user-456", bank_id = None, has_personal_entity = true, - schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string"}, "language": {"type": "string"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string"}, "language": {"type": "string"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject], + _links = Some(DynamicEntityLinksJsonV600( + related = List( + RelatedLinkJsonV600("list", "/obp/v6.0.0/my/customer_preferences", "GET"), + RelatedLinkJsonV600("create", "/obp/v6.0.0/my/customer_preferences", "POST"), + RelatedLinkJsonV600("read", "/obp/v6.0.0/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "GET"), + RelatedLinkJsonV600("update", "/obp/v6.0.0/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "PUT"), + RelatedLinkJsonV600("delete", "/obp/v6.0.0/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "DELETE") + ) + )) ) ) ), @@ -6979,7 +7044,16 @@ trait APIMethods600 { user_id = "user-456", bank_id = None, has_personal_entity = true, - schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string"}, "language": {"type": "string"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string"}, "language": {"type": "string"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject], + _links = Some(DynamicEntityLinksJsonV600( + related = List( + RelatedLinkJsonV600("list", "/obp/v6.0.0/my/customer_preferences", "GET"), + RelatedLinkJsonV600("create", "/obp/v6.0.0/my/customer_preferences", "POST"), + RelatedLinkJsonV600("read", "/obp/v6.0.0/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "GET"), + RelatedLinkJsonV600("update", "/obp/v6.0.0/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "PUT"), + RelatedLinkJsonV600("delete", "/obp/v6.0.0/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "DELETE") + ) + )) ) ) ), 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 fed14e065a..97ac5265d9 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 @@ -305,6 +305,20 @@ case class CacheInfoJsonV600( redis_available: Boolean ) +case class DatabasePoolInfoJsonV600( + pool_name: String, + active_connections: Int, + idle_connections: Int, + total_connections: Int, + threads_awaiting_connection: Int, + maximum_pool_size: Int, + minimum_idle: Int, + connection_timeout_ms: Long, + idle_timeout_ms: Long, + max_lifetime_ms: Long, + keepalive_time_ms: Long +) + case class PostCustomerJsonV600( legal_name: String, customer_number: Option[String] = None, @@ -486,6 +500,12 @@ case class AbacPoliciesJsonV600( policies: List[AbacPolicyJsonV600] ) +// HATEOAS-style links for dynamic entity discoverability +case class RelatedLinkJsonV600(rel: String, href: String, method: String) +case class DynamicEntityLinksJsonV600( + related: List[RelatedLinkJsonV600] +) + // Dynamic Entity definition with fully predictable structure (v6.0.0 format) // No dynamic keys - entity name is an explicit field, schema describes the structure case class DynamicEntityDefinitionJsonV600( @@ -494,7 +514,8 @@ case class DynamicEntityDefinitionJsonV600( user_id: String, bank_id: Option[String], has_personal_entity: Boolean, - schema: net.liftweb.json.JsonAST.JObject + schema: net.liftweb.json.JsonAST.JObject, + _links: Option[DynamicEntityLinksJsonV600] = None ) case class MyDynamicEntitiesJsonV600( @@ -1339,6 +1360,28 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ) } + def createDatabasePoolInfoJsonV600(): DatabasePoolInfoJsonV600 = { + import code.api.util.APIUtil + + val ds = APIUtil.vendor.HikariDatasource.ds + val config = APIUtil.vendor.HikariDatasource.config + val pool = ds.getHikariPoolMXBean + + DatabasePoolInfoJsonV600( + pool_name = ds.getPoolName, + active_connections = if (pool != null) pool.getActiveConnections else -1, + idle_connections = if (pool != null) pool.getIdleConnections else -1, + total_connections = if (pool != null) pool.getTotalConnections else -1, + threads_awaiting_connection = if (pool != null) pool.getThreadsAwaitingConnection else -1, + maximum_pool_size = config.getMaximumPoolSize, + minimum_idle = config.getMinimumIdle, + connection_timeout_ms = config.getConnectionTimeout, + idle_timeout_ms = config.getIdleTimeout, + max_lifetime_ms = config.getMaxLifetime, + keepalive_time_ms = config.getKeepaliveTime + ) + } + /** * Create v6.0.0 response for GET /my/dynamic-entities * @@ -1362,6 +1405,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { def createMyDynamicEntitiesJson(dynamicEntities: List[code.dynamicEntity.DynamicEntityCommons]): MyDynamicEntitiesJsonV600 = { import net.liftweb.json.JsonAST._ import net.liftweb.json.parse + import net.liftweb.util.StringHelpers MyDynamicEntitiesJsonV600( dynamic_entities = dynamicEntities.map { entity => @@ -1382,13 +1426,32 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { throw new IllegalStateException(s"Could not extract schema for entity '${entity.entityName}' from metadataJson") ) + // Build HATEOAS-style links for this dynamic entity + val entityName = entity.entityName + val idPlaceholder = StringHelpers.snakify(entityName + "Id").toUpperCase() + val baseUrl = entity.bankId match { + case Some(bankId) => s"/obp/v6.0.0/banks/$bankId/my/$entityName" + case None => s"/obp/v6.0.0/my/$entityName" + } + + val links = DynamicEntityLinksJsonV600( + related = List( + RelatedLinkJsonV600("list", baseUrl, "GET"), + RelatedLinkJsonV600("create", baseUrl, "POST"), + RelatedLinkJsonV600("read", s"$baseUrl/$idPlaceholder", "GET"), + RelatedLinkJsonV600("update", s"$baseUrl/$idPlaceholder", "PUT"), + RelatedLinkJsonV600("delete", s"$baseUrl/$idPlaceholder", "DELETE") + ) + ) + DynamicEntityDefinitionJsonV600( dynamic_entity_id = entity.dynamicEntityId.getOrElse(""), entity_name = entity.entityName, user_id = entity.userId, bank_id = entity.bankId, has_personal_entity = entity.hasPersonalEntity, - schema = schemaObj + schema = schemaObj, + _links = Some(links) ) } ) From 7efbf6389a33f4d46ca1da0644793a5d3785d5c1 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 22 Jan 2026 18:41:01 +0100 Subject: [PATCH 3/5] closing database connections after StoredProcedure calls and Migrations. --- .../scala/code/api/util/migration/Migration.scala | 7 +++++-- .../storedprocedure/StoredProcedureUtils.scala | 15 +++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/migration/Migration.scala b/obp-api/src/main/scala/code/api/util/migration/Migration.scala index e31cdeb085..84cef63c05 100644 --- a/obp-api/src/main/scala/code/api/util/migration/Migration.scala +++ b/obp-api/src/main/scala/code/api/util/migration/Migration.scala @@ -648,8 +648,11 @@ object Migration extends MdcLoggable { if (performWrite) { logFunc(ct) val st = conn.createStatement - st.execute(ct) - st.close + try { + st.execute(ct) + } finally { + st.close() + } } ct } diff --git a/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureUtils.scala b/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureUtils.scala index 7ca06811c7..12aa0b4d79 100644 --- a/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureUtils.scala +++ b/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureUtils.scala @@ -58,12 +58,15 @@ object StoredProcedureUtils extends MdcLoggable{ val sql = s"{ CALL $procedureName(?, ?) }" val callableStatement = conn.prepareCall(sql) - callableStatement.setString(1, procedureParam) - - callableStatement.registerOutParameter(2, java.sql.Types.LONGVARCHAR) - // callableStatement.setString(2, "") // MS sql server must comment this line, other DB need check. - callableStatement.executeUpdate() - callableStatement.getString(2) + try { + callableStatement.setString(1, procedureParam) + callableStatement.registerOutParameter(2, java.sql.Types.LONGVARCHAR) + // callableStatement.setString(2, "") // MS sql server must comment this line, other DB need check. + callableStatement.executeUpdate() + callableStatement.getString(2) + } finally { + callableStatement.close() + } } logger.debug(s"${StoredProcedureConnector_vDec2019.toString} inBoundJson: $procedureName = $responseJson" ) Connector.extractAdapterResponse[T](responseJson, Empty) From 26f2fd192b1eb3856d38f13c5f3b708ca6e42c3c Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 26 Jan 2026 10:51:33 +0100 Subject: [PATCH 4/5] Docfix: Mentioning content=dynamic for resource docs --- .../main/scala/code/api/util/Glossary.scala | 21 +++++++++++++++++++ .../scala/code/api/v4_0_0/APIMethods400.scala | 16 ++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index fbab12e006..faf074d323 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -367,6 +367,8 @@ object Glossary extends MdcLoggable { | |Dynamic Entities can be found under the **More** list of API Versions. Look for versions starting with `OBPdynamic-entity` or similar in the version selector. | + |To programmatically discover all Dynamic Entity endpoints, use: `GET /resource-docs/API_VERSION/obp?content=dynamic` + | |For more information about Dynamic Entities see ${getGlossaryItemLink("Dynamic-Entities")} | |### Creating Favorites @@ -3316,6 +3318,25 @@ object Glossary extends MdcLoggable { |* PUT /management/system-dynamic-entities/DYNAMIC_ENTITY_ID - Update entity definition |* DELETE /management/system-dynamic-entities/DYNAMIC_ENTITY_ID - Delete entity (and all its data) | +|**Discovering Dynamic Entity Endpoints (for application developers):** +| +|Once Dynamic Entities are created, their auto-generated CRUD endpoints are documented in the Resource Docs API. To programmatically discover all available Dynamic Entity endpoints, use: +| +|``` +|GET /resource-docs/API_VERSION/obp?content=dynamic +|``` +| +|For example: `GET /resource-docs/v5.1.0/obp?content=dynamic` +| +|This returns documentation for all dynamic endpoints (both Dynamic Entities and Dynamic Endpoints) including: +| +|* Endpoint paths and HTTP methods +|* Request and response schemas with examples +|* Required roles and authentication +|* Field descriptions and types +| +|You can also get this documentation in OpenAPI/Swagger format for code generation and API client tooling. +| |**Required roles to manage Dynamic Entities:** | |* CanCreateSystemLevelDynamicEntity diff --git a/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala b/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala index 123c6390f3..efd26036ac 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala @@ -2221,6 +2221,14 @@ trait APIMethods400 extends MdcLoggable { | |FYI Dynamic Entities and Dynamic Endpoints are listed in the Resource Doc endpoints by adding content=dynamic to the path. They are cached differently to static endpoints. | + |**Discovering the generated endpoints:** + | + |After creating a Dynamic Entity, OBP automatically generates CRUD endpoints. To discover these endpoints programmatically, use: + | + |`GET /resource-docs/API_VERSION/obp?content=dynamic` + | + |This returns documentation for all dynamic endpoints including paths, schemas, and required roles. + | |For more information about Dynamic Entities see ${Glossary .getGlossaryItemLink("Dynamic-Entities")} | @@ -2430,6 +2438,14 @@ trait APIMethods400 extends MdcLoggable { | |FYI Dynamic Entities and Dynamic Endpoints are listed in the Resource Doc endpoints by adding content=dynamic to the path. They are cached differently to static endpoints. | + |**Discovering the generated endpoints:** + | + |After creating a Dynamic Entity, OBP automatically generates CRUD endpoints. To discover these endpoints programmatically, use: + | + |`GET /resource-docs/API_VERSION/obp?content=dynamic` + | + |This returns documentation for all dynamic endpoints including paths, schemas, and required roles. + | |For more information about Dynamic Entities see ${Glossary .getGlossaryItemLink("Dynamic-Entities")} | From e08d25520e680810eada2cc8637f3d5f1821b22c Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 26 Jan 2026 13:03:35 +0100 Subject: [PATCH 5/5] Dynamic Entity Example id is snake case e.g. piano_id rather than pianoId --- .../code/api/dynamic/entity/helper/DynamicEntityHelper.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala index 9a5414d4ad..753b079cc0 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala @@ -500,7 +500,7 @@ case class DynamicEntityInfo(definition: String, entityName: String, bankId: Opt val subEntities: List[DynamicEntityInfo] = Nil - val idName = StringUtils.uncapitalize(entityName) + "Id" + val idName = StringHelpers.snakify(entityName) + "_id" val listName = StringHelpers.snakify(entityName).replaceFirst("[-_]*$", "_list")