From aaf3e61313406b99707b724bafe816f94a9d0011 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 5 Dec 2025 11:56:31 +0100 Subject: [PATCH 1/7] v6.0.0 get webui_props --- .../scala/code/api/v6_0_0/APIMethods600.scala | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) 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 6cbe173cb..399d5a2d1 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 @@ -11,6 +11,7 @@ import code.api.util.ApiRole._ import code.api.util.ApiTag._ import code.api.util.ErrorMessages.{$UserNotLoggedIn, InvalidDateFormat, InvalidJsonFormat, UnknownError, _} import code.api.util.FutureUtil.EndpointContext +import code.api.util.Glossary import code.api.util.NewStyle.HttpCode import code.api.util.{APIUtil, CallContext, DiagnosticDynamicEntityCheck, ErrorMessages, NewStyle, RateLimitingUtil} import code.api.util.NewStyle.function.extractQueryParams @@ -3420,6 +3421,109 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + getWebUiProps, + implementedInApiVersion, + nameOf(getWebUiProps), + "GET", + "/management/webui_props", + "Get WebUiProps", + s""" + | + |Get WebUiProps - properties that configure the Web UI behavior and appearance. + | + |Properties with names starting with "webui_" can be stored in the database and managed via API. + | + |**Data Sources:** + | + |1. **Explicit WebUiProps (Database)**: Custom values created/updated via the API and stored in the database. + | + |2. **Implicit WebUiProps (Configuration File)**: Default values defined in the `sample.props.template` configuration file. + | + |**Query Parameter:** + | + |* `what` (optional, string, default: "active") + | - `active`: Returns explicit props from database + implicit (default) props from configuration file + | - When both sources have the same property name, the database value takes precedence + | - Implicit props are marked with `webUiPropsId = "default"` + | - `database`: Returns only explicit props from the database + | - `config`: Returns only implicit (default) props from configuration file + | + |**Examples:** + | + |Get database props combined with defaults (default behavior): + |${getObpApiRoot}/v6.0.0/management/webui_props + |${getObpApiRoot}/v6.0.0/management/webui_props?what=active + | + |Get only database-stored props: + |${getObpApiRoot}/v6.0.0/management/webui_props?what=database + | + |Get only default props from configuration: + |${getObpApiRoot}/v6.0.0/management/webui_props?what=config + | + |For more details about WebUI Props, including how to set config file defaults and precedence order, see ${Glossary.getGlossaryItemLink("webui_props")}. + | + |""", + EmptyBody, + ListResult( + "webui_props", + (List(WebUiPropsCommons("webui_api_explorer_url", "https://apiexplorer.openbankproject.com", Some("web-ui-props-id")))) + ) + , + List( + UserNotLoggedIn, + UserHasMissingRoles, + UnknownError + ), + List(apiTagWebUiProps), + Some(List(canGetWebUiProps)) + ) + + + lazy val getWebUiProps: OBPEndpoint = { + case "management" :: "webui_props":: Nil JsonGet req => { + cc => implicit val ec = EndpointContext(Some(cc)) + val what = ObpS.param("what").getOrElse("active") + logger.info(s"========== GET /obp/v6.0.0/management/webui_props called with what=$what ==========") + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.tryons(s"""$InvalidFilterParameterFormat `what` must be one of: active, database, config. Current value: $what""", 400, callContext) { + what match { + case "active" | "database" | "config" => true + case _ => false + } + } + _ <- NewStyle.function.hasEntitlement("", u.userId, ApiRole.canGetWebUiProps, callContext) + explicitWebUiProps <- Future{ MappedWebUiPropsProvider.getAll() } + implicitWebUiProps = getWebUIPropsPairs.map(webUIPropsPairs=>WebUiPropsCommons(webUIPropsPairs._1, webUIPropsPairs._2, webUiPropsId= Some("default"))) + result = what match { + case "database" => + // Return only database props + explicitWebUiProps + case "config" => + // Return only config file props + implicitWebUiProps.distinct + case "active" => + // Return database props + config props (removing duplicates, database takes precedence) + val implicitWebUiPropsRemovedDuplicated = if(explicitWebUiProps.nonEmpty){ + val duplicatedProps : List[WebUiPropsCommons]= explicitWebUiProps.map(explicitWebUiProp => implicitWebUiProps.filter(_.name == explicitWebUiProp.name)).flatten + implicitWebUiProps diff duplicatedProps + } else { + implicitWebUiProps.distinct + } + explicitWebUiProps ++ implicitWebUiPropsRemovedDuplicated + } + } yield { + logger.info(s"========== GET /obp/v6.0.0/management/webui_props returning ${result.size} records ==========") + result.foreach { prop => + logger.info(s" - name: ${prop.name}, value: ${prop.value}, webUiPropsId: ${prop.webUiPropsId}") + } + logger.info(s"========== END GET /obp/v6.0.0/management/webui_props ==========") + (ListResult("webui_props", result), HttpCode.`200`(callContext)) + } + } + } + } } From 0989f158a33b4e84da193151897c9c26ae4e0b95 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 5 Dec 2025 12:37:33 +0100 Subject: [PATCH 2/7] fixing email for user validation --- 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 399d5a2d1..705adda05 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 @@ -2473,7 +2473,7 @@ trait APIMethods600 { Constant.HostName + "/" + code.model.dataAccess.AuthUser.validateUserPath.mkString("/") + "/" + java.net.URLEncoder.encode(savedUser.uniqueId.get, "UTF-8") case _ => // Default to portal_external_url property if available, otherwise fall back to hostname - APIUtil.getPropsValue("portal_external_url", Constant.HostName) + "/" + code.model.dataAccess.AuthUser.validateUserPath.mkString("/") + "/" + java.net.URLEncoder.encode(savedUser.uniqueId.get, "UTF-8") + APIUtil.getPropsValue("portal_external_url", Constant.HostName) + "/user-validation?token=" + java.net.URLEncoder.encode(savedUser.uniqueId.get, "UTF-8") } val textContent = Some(s"Welcome! Please validate your account by clicking the following link: $emailValidationLink") From c64a5d1089e894d5288c060539b9c1802f421f5d Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 5 Dec 2025 18:12:13 +0100 Subject: [PATCH 3/7] role name field increase in entitlement etc. --- .../helper/DynamicEndpointHelper.scala | 4 +- .../code/api/util/migration/Migration.scala | 8 + .../MigrationOfRoleNameFieldLength.scala | 80 +++++++++ .../code/entitlement/MappedEntitlements.scala | 2 +- .../MappedEntitlementRquests.scala | 2 +- .../code/scope/MappedScopesProvider.scala | 2 +- .../code/api/v6_0_0/WebUiPropsTest.scala | 153 ++++++++++++++++++ 7 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/migration/MigrationOfRoleNameFieldLength.scala create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/WebUiPropsTest.scala diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpointHelper.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpointHelper.scala index 7a8beb95a..80f3b07d6 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpointHelper.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpointHelper.scala @@ -355,8 +355,8 @@ object DynamicEndpointHelper extends RestHelper { s"$roleNamePrefix$prettySummary${entitlementSuffix(path)}" } // substring role name to avoid it have over the maximum length of db column. - if(roleName.size > 64) { - roleName = StringUtils.substring(roleName, 0, 53) + roleName.hashCode() + if(roleName.size > 255) { + roleName = StringUtils.substring(roleName, 0, 244) + roleName.hashCode() } Some(List( ApiRole.getOrCreateDynamicApiRole(roleName, bankId.isDefined) 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 1ccf27d05..e31cdeb08 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 @@ -106,6 +106,7 @@ object Migration extends MdcLoggable { renameCustomerRoleNames() addUniqueIndexOnResourceUserUserId() addIndexOnMappedMetricUserId() + alterRoleNameLength() } private def dummyScript(): Boolean = { @@ -545,6 +546,13 @@ object Migration extends MdcLoggable { MigrationOfUserIdIndexes.addIndexOnMappedMetricUserId(name) } } + + private def alterRoleNameLength(): Boolean = { + val name = nameOf(alterRoleNameLength) + runOnce(name) { + MigrationOfRoleNameFieldLength.alterRoleNameLength(name) + } + } } /** diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfRoleNameFieldLength.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfRoleNameFieldLength.scala new file mode 100644 index 000000000..891c345f1 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfRoleNameFieldLength.scala @@ -0,0 +1,80 @@ +package code.api.util.migration + +import code.api.util.APIUtil +import code.api.util.migration.Migration.{DbFunction, saveLog} +import code.entitlement.MappedEntitlement +import code.entitlementrequest.MappedEntitlementRequest +import code.scope.MappedScope +import net.liftweb.common.Full +import net.liftweb.mapper.Schemifier + +import java.time.format.DateTimeFormatter +import java.time.{ZoneId, ZonedDateTime} + +object MigrationOfRoleNameFieldLength { + + val oneDayAgo = ZonedDateTime.now(ZoneId.of("UTC")).minusDays(1) + val oneYearInFuture = ZonedDateTime.now(ZoneId.of("UTC")).plusYears(1) + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm'Z'") + + def alterRoleNameLength(name: String): Boolean = { + val entitlementTableExists = DbFunction.tableExists(MappedEntitlement) + val entitlementRequestTableExists = DbFunction.tableExists(MappedEntitlementRequest) + val scopeTableExists = DbFunction.tableExists(MappedScope) + + if (!entitlementTableExists || !entitlementRequestTableExists || !scopeTableExists) { + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + val isSuccessful = false + val endDate = System.currentTimeMillis() + val comment: String = + s"""One or more required tables do not exist: + |entitlement table exists: $entitlementTableExists + |entitlementrequest table exists: $entitlementRequestTableExists + |scope table exists: $scopeTableExists + |""".stripMargin + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + return isSuccessful + } + + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + var isSuccessful = false + + val executedSql = + DbFunction.maybeWrite(true, Schemifier.infoF _) { + APIUtil.getPropsValue("db.driver") match { + case Full(dbDriver) if dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") => + () => + """ + |ALTER TABLE mappedentitlement ALTER COLUMN mrolename varchar(255); + |ALTER TABLE mappedentitlementrequest ALTER COLUMN mrolename varchar(255); + |ALTER TABLE mappedscope ALTER COLUMN mrolename varchar(255); + |""".stripMargin + case _ => + () => + """ + |ALTER TABLE mappedentitlement ALTER COLUMN mrolename TYPE varchar(255); + |ALTER TABLE mappedentitlementrequest ALTER COLUMN mrolename TYPE varchar(255); + |ALTER TABLE mappedscope ALTER COLUMN mrolename TYPE varchar(255); + |""".stripMargin + } + } + + val endDate = System.currentTimeMillis() + val comment: String = + s"""Executed SQL: + |$executedSql + | + |Increased mrolename column length from 64 to 255 characters in three tables: + | - mappedentitlement + | - mappedentitlementrequest + | - mappedscope + | + |This allows for longer dynamic entity names and role names. + |""".stripMargin + isSuccessful = true + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + } +} \ No newline at end of file diff --git a/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala b/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala index 419ad9bf7..08ca93410 100644 --- a/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala +++ b/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala @@ -139,7 +139,7 @@ class MappedEntitlement extends Entitlement object mEntitlementId extends MappedUUID(this) object mBankId extends UUIDString(this) object mUserId extends UUIDString(this) - object mRoleName extends MappedString(this, 64) + object mRoleName extends MappedString(this, 255) object mCreatedByProcess extends MappedString(this, 255) object mGroupId extends MappedString(this, 255) { diff --git a/obp-api/src/main/scala/code/entitlementrequest/MappedEntitlementRquests.scala b/obp-api/src/main/scala/code/entitlementrequest/MappedEntitlementRquests.scala index 507aaf08c..c74213670 100644 --- a/obp-api/src/main/scala/code/entitlementrequest/MappedEntitlementRquests.scala +++ b/obp-api/src/main/scala/code/entitlementrequest/MappedEntitlementRquests.scala @@ -87,7 +87,7 @@ class MappedEntitlementRequest extends EntitlementRequest object mUserId extends UUIDString(this) - object mRoleName extends MappedString(this, 64) + object mRoleName extends MappedString(this, 255) override def entitlementRequestId: String = mEntitlementRequestId.get.toString diff --git a/obp-api/src/main/scala/code/scope/MappedScopesProvider.scala b/obp-api/src/main/scala/code/scope/MappedScopesProvider.scala index a401bdb34..480134275 100644 --- a/obp-api/src/main/scala/code/scope/MappedScopesProvider.scala +++ b/obp-api/src/main/scala/code/scope/MappedScopesProvider.scala @@ -85,7 +85,7 @@ class MappedScope extends Scope object mScopeId extends MappedUUID(this) object mBankId extends UUIDString(this) object mConsumerId extends UUIDString(this) - object mRoleName extends MappedString(this, 64) + object mRoleName extends MappedString(this, 255) override def scopeId: String = mScopeId.get.toString override def bankId: String = mBankId.get diff --git a/obp-api/src/test/scala/code/api/v6_0_0/WebUiPropsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/WebUiPropsTest.scala new file mode 100644 index 000000000..a4a2f0353 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/WebUiPropsTest.scala @@ -0,0 +1,153 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2019, 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._ +import code.api.util.ErrorMessages._ +import code.api.v6_0_0.OBPAPI6_0_0.Implementations6_0_0 +import code.entitlement.Entitlement +import code.webuiprops.WebUiPropsCommons +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 WebUiPropsTest extends V600ServerSetup { + + /** + * Test tags + * Example: To run tests with tag "getPermissions": + * mvn test -D tagsToInclude + * + * This is made possible by the scalatest maven plugin + */ + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint extends Tag(nameOf(Implementations6_0_0.getWebUiProp)) + + val rightEntity = WebUiPropsCommons("webui_api_explorer_url", "https://apiexplorer.openbankproject.com") + val anotherEntity = WebUiPropsCommons("webui_api_manager_url", "https://apimanager.openbankproject.com") + val wrongEntity = WebUiPropsCommons("hello_api_explorer_url", "https://apiexplorer.openbankproject.com") // name not start with "webui_" + + + feature("Get Single WebUiProp by Name v6.0.0") { + + scenario("Get WebUiProp - successful case with explicit prop from database", VersionOfApi, ApiEndpoint) { + // First create a webui prop + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateWebUiProps.toString) + When("We create a webui prop") + val requestCreate = (v6_0_0_Request / "management" / "webui_props").POST <@(user1) + val responseCreate = makePostRequest(requestCreate, write(rightEntity)) + Then("We should get a 201") + responseCreate.code should equal(201) + + When("We get the webui prop by name without active flag") + val requestGet = (v6_0_0_Request / "webui-props" / rightEntity.name).GET + val responseGet = makeGetRequest(requestGet) + Then("We should get a 200") + responseGet.code should equal(200) + val webUiPropJson = responseGet.body.extract[WebUiPropsCommons] + webUiPropJson.name should equal(rightEntity.name) + webUiPropJson.value should equal(rightEntity.value) + } + + scenario("Get WebUiProp - successful case with active=true returns explicit prop", VersionOfApi, ApiEndpoint) { + // First create a webui prop + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateWebUiProps.toString) + When("We create a webui prop") + val requestCreate = (v6_0_0_Request / "management" / "webui_props").POST <@(user1) + val responseCreate = makePostRequest(requestCreate, write(anotherEntity)) + Then("We should get a 201") + responseCreate.code should equal(201) + + When("We get the webui prop by name with active=true") + val requestGet = (v6_0_0_Request / "webui-props" / anotherEntity.name).GET.addQueryParameter("active", "true") + val responseGet = makeGetRequest(requestGet) + Then("We should get a 200") + responseGet.code should equal(200) + val webUiPropJson = responseGet.body.extract[WebUiPropsCommons] + webUiPropJson.name should equal(anotherEntity.name) + webUiPropJson.value should equal(anotherEntity.value) + } + + scenario("Get WebUiProp - not found without active flag", VersionOfApi, ApiEndpoint) { + When("We get a non-existent webui prop by name without active flag") + val requestGet = (v6_0_0_Request / "webui-props" / "webui_non_existent_prop").GET + val responseGet = makeGetRequest(requestGet) + Then("We should get a 400") + responseGet.code should equal(400) + val error = responseGet.body.extract[ErrorMessage] + error.message should include(WebUiPropsNotFoundByName) + } + + scenario("Get WebUiProp - with active=true returns implicit prop from config", VersionOfApi, ApiEndpoint) { + // Test that we can get implicit props from sample.props.template when active=true + When("We get a webui prop by name with active=true that exists in config but not in DB") + // Use a prop that should exist in sample.props.template like webui_sandbox_introduction + val requestGet = (v6_0_0_Request / "webui-props" / "webui_sandbox_introduction").GET.addQueryParameter("active", "true") + val responseGet = makeGetRequest(requestGet) + Then("We should get a 200 with implicit prop") + responseGet.code should equal(200) + val webUiPropJson = responseGet.body.extract[WebUiPropsCommons] + webUiPropJson.name should equal("webui_sandbox_introduction") + webUiPropJson.webUiPropsId should equal(Some("default")) + } + + scenario("Get WebUiProp - invalid active parameter", VersionOfApi, ApiEndpoint) { + When("We get a webui prop with invalid active parameter") + val requestGet = (v6_0_0_Request / "webui-props" / "webui_api_explorer_url").GET.addQueryParameter("active", "invalid") + val responseGet = makeGetRequest(requestGet) + Then("We should get a 400") + responseGet.code should equal(400) + val error = responseGet.body.extract[ErrorMessage] + error.message should include(InvalidFilterParameterFormat) + } + + scenario("Get WebUiProp - database prop takes precedence over config prop when active=true", VersionOfApi, ApiEndpoint) { + // Create a webui prop that overrides a config value + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateWebUiProps.toString) + val customValue = WebUiPropsCommons("webui_get_started_text", "Custom Get Started Text") + When("We create a webui prop that overrides config") + val requestCreate = (v6_0_0_Request / "management" / "webui_props").POST <@(user1) + val responseCreate = makePostRequest(requestCreate, write(customValue)) + Then("We should get a 201") + responseCreate.code should equal(201) + + When("We get the webui prop with active=true") + val requestGet = (v6_0_0_Request / "webui-props" / customValue.name).GET.addQueryParameter("active", "true") + val responseGet = makeGetRequest(requestGet) + Then("We should get the database value, not the config value") + responseGet.code should equal(200) + val webUiPropJson = responseGet.body.extract[WebUiPropsCommons] + webUiPropJson.name should equal(customValue.name) + webUiPropJson.value should equal(customValue.value) + webUiPropJson.webUiPropsId should not equal(Some("default")) + } + } + +} \ No newline at end of file From 280e45557c9b0c2f124300db9a37bf91caf91b68 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 5 Dec 2025 22:41:28 +0100 Subject: [PATCH 4/7] Delete System Dynamic Entity Cascade --- .../main/scala/code/api/util/ApiRole.scala | 3 + .../scala/code/api/v4_0_0/APIMethods400.scala | 94 ++++++++++++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) 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 05898276e..9e25a6556 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -693,6 +693,9 @@ object ApiRole extends MdcLoggable{ case class CanDeleteSystemLevelDynamicEntity(requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteSystemLevelDynamicEntity = CanDeleteSystemLevelDynamicEntity() + case class CanDeleteCascadeSystemDynamicEntity(requiresBankId: Boolean = false) extends ApiRole + lazy val canDeleteCascadeSystemDynamicEntity = CanDeleteCascadeSystemDynamicEntity() + case class CanDeleteBankLevelDynamicEntity(requiresBankId: Boolean = true) extends ApiRole lazy val canDeleteBankLevelDynamicEntity = CanDeleteBankLevelDynamicEntity() 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 3cb99fd0a..a55bd2e0a 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 @@ -14,7 +14,7 @@ import code.api.dynamic.endpoint.helper.practise.{ PractiseEndpoint } import code.api.dynamic.endpoint.helper.{CompiledObjects, DynamicEndpointHelper} -import code.api.dynamic.entity.helper.DynamicEntityInfo +import code.api.dynamic.entity.helper.{DynamicEntityHelper, DynamicEntityInfo} import code.api.util.APIUtil.{fullBoxOrException, _} import code.api.util.ApiRole._ import code.api.util.ApiTag._ @@ -2801,6 +2801,98 @@ trait APIMethods400 extends MdcLoggable { } } + staticResourceDocs += ResourceDoc( + deleteSystemDynamicEntityCascade, + implementedInApiVersion, + nameOf(deleteSystemDynamicEntityCascade), + "DELETE", + "/management/system-dynamic-entities/cascade/DYNAMIC_ENTITY_ID", + "Delete System Level Dynamic Entity Cascade", + s"""Delete a DynamicEntity specified by DYNAMIC_ENTITY_ID and all its data records. + | + |This endpoint performs a cascade delete: + |1. Deletes all data records associated with the dynamic entity + |2. Deletes the dynamic entity definition itself + | + |Use with caution - this operation cannot be undone. + | + |For more information see ${Glossary.getGlossaryItemLink( + "Dynamic-Entities" + )}/ + | + |""", + EmptyBody, + EmptyBody, + List( + $UserNotLoggedIn, + UserHasMissingRoles, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canDeleteCascadeSystemDynamicEntity)) + ) + lazy val deleteSystemDynamicEntityCascade: OBPEndpoint = { + case "management" :: "system-dynamic-entities" :: "cascade" :: dynamicEntityId :: Nil JsonDelete _ => { + cc => + implicit val ec = EndpointContext(Some(cc)) + deleteDynamicEntityCascadeMethod(None, dynamicEntityId, cc) + } + } + + private def deleteDynamicEntityCascadeMethod( + bankId: Option[String], + dynamicEntityId: String, + cc: CallContext + ) = { + for { + // Get the dynamic entity + (entity, _) <- NewStyle.function.getDynamicEntityById( + bankId, + dynamicEntityId, + cc.callContext + ) + // Get all data records for this entity + (box, _) <- NewStyle.function.invokeDynamicConnector( + GET_ALL, + entity.entityName, + None, + None, + entity.bankId, + None, + None, + false, + cc.callContext + ) + resultList: JArray = unboxResult( + box.asInstanceOf[Box[JArray]], + entity.entityName + ) + // Delete all data records + _ <- Future.sequence { + resultList.arr.map { record => + val idFieldName = DynamicEntityHelper.createEntityId(entity.entityName) + val recordId = (record \ idFieldName).asInstanceOf[JString].s + Future { + DynamicDataProvider.connectorMethodProvider.vend.delete( + entity.bankId, + entity.entityName, + recordId, + None, + false + ) + } + } + } + // Delete the dynamic entity definition + deleted: Box[Boolean] <- NewStyle.function.deleteDynamicEntity( + bankId, + dynamicEntityId + ) + } yield { + (deleted, HttpCode.`200`(cc.callContext)) + } + } + private def deleteDynamicEntityMethod( bankId: Option[String], dynamicEntityId: String, From 9d92c1d300a841bd4602a7c9b54b3145765070bc Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 6 Dec 2025 00:33:06 +0100 Subject: [PATCH 5/7] Dynamic Entitity Delete Cascade in v6.0.0 --- .../scala/code/api/v4_0_0/APIMethods400.scala | 90 --------- .../scala/code/api/v6_0_0/APIMethods600.scala | 176 +++++++++++++++++- 2 files changed, 172 insertions(+), 94 deletions(-) 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 a55bd2e0a..5d96f488c 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 @@ -2801,97 +2801,7 @@ trait APIMethods400 extends MdcLoggable { } } - staticResourceDocs += ResourceDoc( - deleteSystemDynamicEntityCascade, - implementedInApiVersion, - nameOf(deleteSystemDynamicEntityCascade), - "DELETE", - "/management/system-dynamic-entities/cascade/DYNAMIC_ENTITY_ID", - "Delete System Level Dynamic Entity Cascade", - s"""Delete a DynamicEntity specified by DYNAMIC_ENTITY_ID and all its data records. - | - |This endpoint performs a cascade delete: - |1. Deletes all data records associated with the dynamic entity - |2. Deletes the dynamic entity definition itself - | - |Use with caution - this operation cannot be undone. - | - |For more information see ${Glossary.getGlossaryItemLink( - "Dynamic-Entities" - )}/ - | - |""", - EmptyBody, - EmptyBody, - List( - $UserNotLoggedIn, - UserHasMissingRoles, - UnknownError - ), - List(apiTagManageDynamicEntity, apiTagApi), - Some(List(canDeleteCascadeSystemDynamicEntity)) - ) - lazy val deleteSystemDynamicEntityCascade: OBPEndpoint = { - case "management" :: "system-dynamic-entities" :: "cascade" :: dynamicEntityId :: Nil JsonDelete _ => { - cc => - implicit val ec = EndpointContext(Some(cc)) - deleteDynamicEntityCascadeMethod(None, dynamicEntityId, cc) - } - } - private def deleteDynamicEntityCascadeMethod( - bankId: Option[String], - dynamicEntityId: String, - cc: CallContext - ) = { - for { - // Get the dynamic entity - (entity, _) <- NewStyle.function.getDynamicEntityById( - bankId, - dynamicEntityId, - cc.callContext - ) - // Get all data records for this entity - (box, _) <- NewStyle.function.invokeDynamicConnector( - GET_ALL, - entity.entityName, - None, - None, - entity.bankId, - None, - None, - false, - cc.callContext - ) - resultList: JArray = unboxResult( - box.asInstanceOf[Box[JArray]], - entity.entityName - ) - // Delete all data records - _ <- Future.sequence { - resultList.arr.map { record => - val idFieldName = DynamicEntityHelper.createEntityId(entity.entityName) - val recordId = (record \ idFieldName).asInstanceOf[JString].s - Future { - DynamicDataProvider.connectorMethodProvider.vend.delete( - entity.bankId, - entity.entityName, - recordId, - None, - false - ) - } - } - } - // Delete the dynamic entity definition - deleted: Box[Boolean] <- NewStyle.function.deleteDynamicEntity( - bankId, - dynamicEntityId - ) - } yield { - (deleted, HttpCode.`200`(cc.callContext)) - } - } private def deleteDynamicEntityMethod( bankId: Option[String], 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 705adda05..bb17479cc 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 @@ -9,7 +9,7 @@ import code.api.util.APIUtil._ import code.api.util.ApiRole import code.api.util.ApiRole._ import code.api.util.ApiTag._ -import code.api.util.ErrorMessages.{$UserNotLoggedIn, InvalidDateFormat, InvalidJsonFormat, UnknownError, _} +import code.api.util.ErrorMessages.{$UserNotLoggedIn, InvalidDateFormat, InvalidJsonFormat, UnknownError, DynamicEntityOperationNotAllowed, _} import code.api.util.FutureUtil.EndpointContext import code.api.util.Glossary import code.api.util.NewStyle.HttpCode @@ -25,6 +25,7 @@ import code.api.v4_0_0.JSONFactory400.createCallsLimitJson import code.api.v5_0_0.JSONFactory500 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.{DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, GroupJsonV600, GroupMembershipJsonV600, GroupMembershipsJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, createActiveCallLimitsJsonV600, createCallLimitJsonV600, createCurrentUsageJson} import code.api.v6_0_0.OBPAPI6_0_0 import code.metrics.APIMetrics @@ -40,16 +41,23 @@ import code.util.Helper.{MdcLoggable, ObpS, SILENCE_IS_GOLDEN} import code.views.Views import code.views.system.ViewDefinition import code.webuiprops.{MappedWebUiPropsProvider, WebUiPropsCommons} +import code.dynamicEntity.DynamicEntityCommons +import code.DynamicData.{DynamicData, DynamicDataProvider} import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model.{CustomerAttribute, _} +import com.openbankproject.commons.model.enums.DynamicEntityOperation._ import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} -import net.liftweb.common.{Empty, Full} +import net.liftweb.common.{Empty, Failure, Full} +import org.apache.commons.lang3.StringUtils import net.liftweb.http.provider.HTTPParam import net.liftweb.http.rest.RestHelper import net.liftweb.json.{Extraction, JsonParser} -import net.liftweb.json.JsonAST.JValue -import net.liftweb.mapper.{By, Descending, MaxRows, OrderBy} +import net.liftweb.json.JsonAST.{JArray, JObject, JString, JValue} +import net.liftweb.json.JsonDSL._ +import net.liftweb.mapper.{By, Descending, MaxRows, NullRef, OrderBy} +import code.api.util.ExampleValue.dynamicEntityResponseBodyExample +import net.liftweb.common.Box import java.text.SimpleDateFormat import java.util.UUID.randomUUID @@ -3524,6 +3532,166 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + getSystemDynamicEntities, + implementedInApiVersion, + nameOf(getSystemDynamicEntities), + "GET", + "/management/system-dynamic-entities", + "Get System Dynamic Entities", + s"""Get all System Dynamic Entities with record counts. + | + |Each dynamic entity in the response includes a `record_count` field showing how many data records exist for that entity. + | + |For more information see ${Glossary.getGlossaryItemLink( + "Dynamic-Entities" + )} """, + EmptyBody, + ListResult( + "dynamic_entities", + List(dynamicEntityResponseBodyExample) + ), + List( + $UserNotLoggedIn, + UserHasMissingRoles, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canGetSystemLevelDynamicEntities)) + ) + + lazy val getSystemDynamicEntities: OBPEndpoint = { + case "management" :: "system-dynamic-entities" :: Nil JsonGet req => { + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + dynamicEntities <- Future( + NewStyle.function.getDynamicEntities(None, false) + ) + } yield { + val listCommons: List[DynamicEntityCommons] = dynamicEntities.sortBy(_.entityName) + val jObjectsWithCounts = listCommons.map { entity => + val recordCount = DynamicData.count( + By(DynamicData.DynamicEntityName, entity.entityName), + By(DynamicData.IsPersonalEntity, false), + if (entity.bankId.isEmpty) NullRef(DynamicData.BankId) else By(DynamicData.BankId, entity.bankId.get) + ) + entity.jValue.asInstanceOf[JObject] ~ ("record_count" -> recordCount) + } + ( + ListResult("dynamic_entities", jObjectsWithCounts), + HttpCode.`200`(cc.callContext) + ) + } + } + } + + private def unboxResult[T: Manifest](box: Box[T], entityName: String): T = { + if (box.isInstanceOf[Failure]) { + val failure = box.asInstanceOf[Failure] + // change the internal db column name 'dynamicdataid' to entity's id name + val msg = failure.msg.replace( + DynamicData.DynamicDataId.dbColumnName, + StringUtils.uncapitalize(entityName) + "Id" + ) + val changedMsgFailure = failure.copy(msg = s"$InternalServerError $msg") + fullBoxOrException[T](changedMsgFailure) + } + box.openOrThrowException(s"$UnknownError ") + } + + staticResourceDocs += ResourceDoc( + deleteSystemDynamicEntityCascade, + implementedInApiVersion, + nameOf(deleteSystemDynamicEntityCascade), + "DELETE", + "/management/system-dynamic-entities/cascade/DYNAMIC_ENTITY_ID", + "Delete System Level Dynamic Entity Cascade", + s"""Delete a DynamicEntity specified by DYNAMIC_ENTITY_ID and all its data records. + | + |This endpoint performs a cascade delete: + |1. Deletes all data records associated with the dynamic entity + |2. Deletes the dynamic entity definition itself + | + |Use with caution - this operation cannot be undone. + | + |For more information see ${Glossary.getGlossaryItemLink( + "Dynamic-Entities" + )}/ + | + |""", + EmptyBody, + EmptyBody, + List( + $UserNotLoggedIn, + UserHasMissingRoles, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canDeleteCascadeSystemDynamicEntity)) + ) + lazy val deleteSystemDynamicEntityCascade: OBPEndpoint = { + case "management" :: "system-dynamic-entities" :: "cascade" :: dynamicEntityId :: Nil JsonDelete _ => { + cc => + implicit val ec = EndpointContext(Some(cc)) + deleteDynamicEntityCascadeMethod(None, dynamicEntityId, cc) + } + } + + private def deleteDynamicEntityCascadeMethod( + bankId: Option[String], + dynamicEntityId: String, + cc: CallContext + ) = { + for { + // Get the dynamic entity + (entity, _) <- NewStyle.function.getDynamicEntityById( + bankId, + dynamicEntityId, + cc.callContext + ) + // Get all data records for this entity + (box, _) <- NewStyle.function.invokeDynamicConnector( + GET_ALL, + entity.entityName, + None, + None, + entity.bankId, + None, + None, + false, + cc.callContext + ) + resultList: JArray = unboxResult( + box.asInstanceOf[Box[JArray]], + entity.entityName + ) + // Delete all data records + _ <- Future.sequence { + resultList.arr.map { record => + val idFieldName = DynamicEntityHelper.createEntityId(entity.entityName) + val recordId = (record \ idFieldName).asInstanceOf[JString].s + Future { + DynamicDataProvider.connectorMethodProvider.vend.delete( + entity.bankId, + entity.entityName, + recordId, + None, + false + ) + } + } + } + // Delete the dynamic entity definition + deleted: Box[Boolean] <- NewStyle.function.deleteDynamicEntity( + bankId, + dynamicEntityId + ) + } yield { + (deleted, HttpCode.`200`(cc.callContext)) + } + } + } } From cc812f230f869d8e7f02045217db0f8702ceacdc Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 6 Dec 2025 02:21:30 +0100 Subject: [PATCH 6/7] reference type checks entity record id and entity name only --- .../dynamicEntity/DynamicEntityProvider.scala | 21 ++++++++++++------- .../MapppedDynamicDataProvider.scala | 11 ++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala index 1a0d8db7b..35c13f91c 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala @@ -4,6 +4,7 @@ import java.util.regex.Pattern import code.api.util.ErrorMessages.DynamicEntityInstanceValidateFail import code.api.util.{APIUtil, CallContext, NewStyle} +import code.util.Helper.MdcLoggable import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model.enums.{DynamicEntityFieldType, DynamicEntityOperation} import com.openbankproject.commons.model._ @@ -144,7 +145,7 @@ trait DynamicEntityT { } } -object ReferenceType { +object ReferenceType extends MdcLoggable { private def recoverFn(fieldName: String, value: String, entityName: String): PartialFunction[Throwable, String] = { case _: Throwable => s"entity '$entityName' not found by the value '$value', the field name is '$fieldName'." @@ -360,14 +361,18 @@ object ReferenceType { } else { val dynamicEntityName = typeName.replace("reference:", "") val errorMsg = s"""$dynamicEntityName not found by the id value '$value', propertyName is '$propertyName'""" - NewStyle.function.invokeDynamicConnector(DynamicEntityOperation.GET_ONE,dynamicEntityName, None, Some(value), None, None, None, false,callContext) - .recover { - case _: Throwable => errorMsg - } - .map { - case (Full(_), _) => "" - case _ => errorMsg + logger.info(s"========== Validating reference field: propertyName='$propertyName', typeName='$typeName', dynamicEntityName='$dynamicEntityName', value='$value' ==========") + + Future { + val exists = code.DynamicData.MappedDynamicDataProvider.existsById(dynamicEntityName, value) + if (exists) { + logger.info(s"========== Reference validation SUCCESS: propertyName='$propertyName', dynamicEntityName='$dynamicEntityName', value='$value' ==========") + "" + } else { + logger.warn(s"========== Reference validation FAILED: propertyName='$propertyName', dynamicEntityName='$dynamicEntityName', value='$value' ==========") + errorMsg } + } } } } diff --git a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala index 7703d9728..b7047e4e3 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala @@ -25,6 +25,17 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm saveOrUpdate(bankId, entityName, requestBody, userId, isPersonalEntity, dynamicData) } + // Separate method for reference validation - only checks ID and entity name exist + def existsById(entityName: String, id: String): Boolean = { + println(s"========== Reference validation: checking if DynamicDataId='$id' exists for DynamicEntityName='$entityName' ==========") + val exists = DynamicData.count( + By(DynamicData.DynamicDataId, id), + By(DynamicData.DynamicEntityName, entityName) + ) > 0 + println(s"========== Reference validation result: exists=$exists ==========") + exists + } + override def get(bankId: Option[String],entityName: String, id: String, userId: Option[String], isPersonalEntity: Boolean): Box[DynamicDataT] = { if(bankId.isEmpty && !isPersonalEntity ){ //isPersonalEntity == false, get all the data, no need for specific userId. //forced the empty also to a error here. this is get Dynamic by Id, if it return Empty, better show the error in this level. From eb49ea7593f8ffddf8ea4c27d105e015f5b1cb02 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 8 Dec 2025 12:14:28 +0100 Subject: [PATCH 7/7] JSON type in Dynamic Entity --- .../scala/code/api/util/ExampleValue.scala | 8 +++++++ .../main/scala/code/api/util/Glossary.scala | 2 +- .../scala/code/api/v4_0_0/APIMethods400.scala | 22 +++++++++++++++++++ .../commons/model/enums/Enumerations.scala | 11 ++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/util/ExampleValue.scala b/obp-api/src/main/scala/code/api/util/ExampleValue.scala index e5d33893a..b2fd736fb 100644 --- a/obp-api/src/main/scala/code/api/util/ExampleValue.scala +++ b/obp-api/src/main/scala/code/api/util/ExampleValue.scala @@ -2538,6 +2538,14 @@ object ExampleValue { | "type": "integer", | "example": "698761728934", | "description": "description of **number** field, can be markdown text." + | }, + | "metadata": { + | "type": "json", + | "example": { + | "tags": ["important", "verified"], + | "settings": {"color": "blue", "priority": 1} + | }, + | "description": "description of **metadata** field (JSON object or array), can be markdown text." | } | } | } 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 ca5533f42..4de1b85b2 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -2966,7 +2966,7 @@ object Glossary extends MdcLoggable { | |**Supported field types:** | -|STRING, INTEGER, DOUBLE, BOOLEAN, DATE_WITH_DAY (format: yyyy-MM-dd), and reference types (foreign keys) +|STRING, INTEGER, DOUBLE, BOOLEAN, DATE_WITH_DAY (format: yyyy-MM-dd), JSON (objects and arrays), and reference types (foreign keys) | |**The hasPersonalEntity flag:** | 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 5d96f488c..81264a54d 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 @@ -2321,6 +2321,17 @@ trait APIMethods400 extends MdcLoggable { | "summary": { | "type": "string", | "example": "User received 'No such price' error using Stripe API" + | }, + | "custom_metadata": { + | "type": "json", + | "example": { + | "priority": "high", + | "tags": ["support", "billing"], + | "context": { + | "page": "checkout", + | "step": 3 + | } + | } | } | } | }, @@ -2513,6 +2524,17 @@ trait APIMethods400 extends MdcLoggable { | "summary": { | "type": "string", | "example": "User received 'No such price' error using Stripe API" + | }, + | "custom_metadata": { + | "type": "json", + | "example": { + | "priority": "high", + | "tags": ["support", "billing"], + | "context": { + | "page": "checkout", + | "step": 3 + | } + | } | } | } | }, diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala index ec8c7a44e..35853e5b3 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala @@ -249,6 +249,17 @@ object DynamicEntityFieldType extends OBPEnumeration[DynamicEntityFieldType]{ override def wrongTypeMsg: String = s"the value's type should be $this, format is $dateFormat." } + object json extends Value { + val jValueType = classOf[JValue] + override def isJValueValid(jValue: JValue): Boolean = { + jValue match { + case _: JObject => true + case _: JArray => true + case _ => false + } + } + override def wrongTypeMsg: String = "the value's type should be a JSON object or array." + } //object array extends Value{val jValueType = classOf[JArray]} //object `object` extends Value{val jValueType = classOf[JObject]} //TODO in the future, we consider support nested type }