From 5f7bbc3e5fa9988a8f1143459c2950431fd441e4 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 17 Jan 2026 09:25:04 +0100 Subject: [PATCH 01/25] ABAC Error message codes --- .../scala/code/api/util/ErrorMessages.scala | 10 ++ .../scala/code/api/v6_0_0/APIMethods600.scala | 127 ++++++++++++++++-- 2 files changed, 129 insertions(+), 8 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 87f28e693c..572393a371 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -649,6 +649,16 @@ object ErrorMessages { val CannotGetUserInvitation = "OBP-37882: Cannot get user invitation." val CannotFindUserInvitation = "OBP-37883: Cannot find user invitation." + // ABAC Rule related messages (OBP-38XXX) + val AbacRuleValidationFailed = "OBP-38001: ABAC rule validation failed. The rule code could not be validated." + val AbacRuleCompilationFailed = "OBP-38002: ABAC rule compilation failed. The rule code contains syntax errors or invalid Scala code." + val AbacRuleTypeMismatch = "OBP-38003: ABAC rule type mismatch. The rule code must return a Boolean value but returns a different type." + val AbacRuleSyntaxError = "OBP-38004: ABAC rule syntax error. The rule code contains invalid syntax." + val AbacRuleFieldReferenceError = "OBP-38005: ABAC rule field reference error. The rule code references fields or objects that do not exist." + val AbacRuleCodeEmpty = "OBP-38006: ABAC rule code must not be empty." + val AbacRuleNotFound = "OBP-38007: ABAC rule not found. Please specify a valid value for ABAC_RULE_ID." + val AbacRuleNotActive = "OBP-38008: ABAC rule is not active." + val AbacRuleExecutionFailed = "OBP-38009: ABAC rule execution failed. An error occurred while executing the rule." // Transaction Request related messages (OBP-40XXX) val InvalidTransactionRequestType = "OBP-40001: Invalid value for TRANSACTION_REQUEST_TYPE" 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 9b1ca4dbae..08d294bd42 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 @@ -13,8 +13,10 @@ import code.api.util.ApiTag._ import code.api.util.ErrorMessages.{$AuthenticatedUserIsRequired, InvalidDateFormat, InvalidJsonFormat, UnknownError, DynamicEntityOperationNotAllowed, _} import code.api.util.FutureUtil.EndpointContext import code.api.util.Glossary +import code.api.util.JsonSchemaGenerator import code.api.util.NewStyle.HttpCode import code.api.util.{APIUtil, CallContext, DiagnosticDynamicEntityCheck, ErrorMessages, NewStyle, RateLimitingUtil} +import net.liftweb.json import code.api.util.NewStyle.function.extractQueryParams import code.api.util.newstyle.ViewNewStyle import code.api.v3_0_0.JSONFactory300 @@ -52,7 +54,8 @@ import com.openbankproject.commons.model._ import com.openbankproject.commons.model.enums.DynamicEntityOperation._ import com.openbankproject.commons.model.enums.UserAttributeType import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} -import net.liftweb.common.{Empty, Failure, Full} +import net.liftweb.common.{Box, Empty, Failure, Full} +import net.liftweb.util.Helpers.tryo import org.apache.commons.lang3.StringUtils import net.liftweb.http.provider.HTTPParam import net.liftweb.http.rest.RestHelper @@ -5531,7 +5534,7 @@ trait APIMethods600 { validateJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, callContext) { json.extract[ValidateAbacRuleJsonV600] } - _ <- NewStyle.function.tryons(s"Rule code must not be empty", 400, callContext) { + _ <- NewStyle.function.tryons(s"$AbacRuleCodeEmpty", 400, callContext) { validateJson.rule_code.trim.nonEmpty } validationResult <- Future { @@ -5544,28 +5547,40 @@ trait APIMethods600 { case Failure(errorMsg, _, _) => // Extract error details from the error message val cleanError = errorMsg.replace("Invalid ABAC rule code: ", "").replace("Failed to compile ABAC rule: ", "") + + // Determine the proper OBP error message and error type + val (obpErrorMessage, errorType) = if (cleanError.toLowerCase.contains("type mismatch") || cleanError.toLowerCase.contains("found:") && cleanError.toLowerCase.contains("required: boolean")) { + (AbacRuleTypeMismatch, "TypeError") + } else if (cleanError.toLowerCase.contains("syntax") || cleanError.toLowerCase.contains("parse")) { + (AbacRuleSyntaxError, "SyntaxError") + } else if (cleanError.toLowerCase.contains("not found") || cleanError.toLowerCase.contains("not a member")) { + (AbacRuleFieldReferenceError, "FieldReferenceError") + } else if (cleanError.toLowerCase.contains("compilation failed") || cleanError.toLowerCase.contains("reflective compilation has failed")) { + (AbacRuleCompilationFailed, "CompilationError") + } else { + (AbacRuleValidationFailed, "ValidationError") + } + Full(ValidateAbacRuleFailureJsonV600( valid = false, error = cleanError, - message = "Rule validation failed", + message = obpErrorMessage, details = ValidateAbacRuleErrorDetailsJsonV600( - error_type = if (cleanError.toLowerCase.contains("syntax")) "SyntaxError" - else if (cleanError.toLowerCase.contains("type")) "TypeError" - else "CompilationError" + error_type = errorType ) )) case Empty => Full(ValidateAbacRuleFailureJsonV600( valid = false, error = "Unknown validation error", - message = "Rule validation failed", + message = AbacRuleValidationFailed, details = ValidateAbacRuleErrorDetailsJsonV600( error_type = "UnknownError" ) )) } } map { - unboxFullOrFail(_, callContext, "Validation failed", 400) + unboxFullOrFail(_, callContext, AbacRuleValidationFailed, 400) } } yield { (validationResult, HttpCode.`200`(callContext)) @@ -6293,6 +6308,102 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + getMessageDocsJsonSchema, + implementedInApiVersion, + nameOf(getMessageDocsJsonSchema), + "GET", + "/message-docs/CONNECTOR/json-schema", + "Get Message Docs as JSON Schema", + """Returns message documentation as JSON Schema format for code generation in any language. + | + |This endpoint provides machine-readable schemas instead of just examples, making it ideal for: + |- AI-powered code generation + |- Automatic adapter creation in multiple languages + |- Type-safe client generation with tools like quicktype + | + |**Supported Connectors:** + |- rabbitmq_vOct2024 - RabbitMQ connector message schemas + |- rest_vMar2019 - REST connector message schemas + |- akka_vDec2018 - Akka connector message schemas + |- kafka_vMay2019 - Kafka connector message schemas (if available) + | + |**Code Generation Examples:** + | + |Generate Scala code with Circe: + |```bash + |curl https://api.../message-docs/rabbitmq_vOct2024/json-schema > schemas.json + |quicktype -s schema schemas.json -o Messages.scala --framework circe + |``` + | + |Generate Python code: + |```bash + |quicktype -s schema schemas.json -o messages.py --lang python + |``` + | + |Generate TypeScript code: + |```bash + |quicktype -s schema schemas.json -o messages.ts --lang typescript + |``` + | + |**Schema Structure:** + |Each message includes: + |- `process` - The connector method name (e.g., "obp.getAdapterInfo") + |- `description` - Human-readable description of what the message does + |- `outbound_schema` - JSON Schema for request messages (OBP-API -> Adapter) + |- `inbound_schema` - JSON Schema for response messages (Adapter -> OBP-API) + | + |All nested type definitions are included in the `definitions` section for reuse. + | + |**Authentication:** + |This endpoint is publicly accessible (no authentication required) to facilitate adapter development. + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + InvalidConnector, + UnknownError + ), + List(apiTagDocumentation, apiTagApi) + ) + + lazy val getMessageDocsJsonSchema: OBPEndpoint = { + case "message-docs" :: connector :: "json-schema" :: Nil JsonGet _ => { + cc => { + implicit val ec = EndpointContext(Some(cc)) + for { + (_, callContext) <- anonymousAccess(cc) + cacheKey = s"message-docs-json-schema-$connector" + cacheValueFromRedis = Caching.getStaticSwaggerDocCache(cacheKey) + jsonSchema <- if (cacheValueFromRedis.isDefined) { + NewStyle.function.tryons(s"$UnknownError Cannot parse cached JSON Schema.", 400, callContext) { + json.parse(cacheValueFromRedis.get).asInstanceOf[JObject] + } + } else { + NewStyle.function.tryons(s"$UnknownError Cannot generate JSON Schema.", 400, callContext) { + val connectorObjectBox = tryo{Connector.getConnectorInstance(connector)} + val connectorObject = unboxFullOrFail( + connectorObjectBox, + callContext, + s"$InvalidConnector Current input is: $connector. Valid connectors include: rabbitmq_vOct2024, rest_vMar2019, akka_vDec2018" + ) + val schema = JsonSchemaGenerator.messageDocsToJsonSchema( + connectorObject.messageDocs.toList, + connector + ) + val schemaString = json.compactRender(schema) + Caching.setStaticSwaggerDocCache(cacheKey, schemaString) + schema + } + } + } yield { + (jsonSchema, HttpCode.`200`(callContext)) + } + } + } + } + } } From a9f42f905ff025d407c3430e34d3a48fcc7a61f6 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 17 Jan 2026 10:27:45 +0100 Subject: [PATCH 02/25] Tagging Message Doc related endpoints. --- obp-api/src/main/scala/code/api/util/ApiTag.scala | 1 + obp-api/src/main/scala/code/api/v2_2_0/APIMethods220.scala | 2 +- obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala | 2 +- obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala | 2 +- 4 files changed, 4 insertions(+), 3 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 38208d32d8..da98f2548f 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -73,6 +73,7 @@ object ApiTag { val apiTagFx = ResourceDocTag("FX") val apiTagMessage = ResourceDocTag("Customer-Message") val apiTagMetric = ResourceDocTag("Metric") + val apiTagMessageDoc = ResourceDocTag("Message-Doc") val apiTagDocumentation = ResourceDocTag("Documentation") val apiTagBerlinGroup = ResourceDocTag("Berlin-Group") val apiTagSigningBaskets = ResourceDocTag("Signing Baskets") diff --git a/obp-api/src/main/scala/code/api/v2_2_0/APIMethods220.scala b/obp-api/src/main/scala/code/api/v2_2_0/APIMethods220.scala index eeba0a8aaa..08ef79ea69 100644 --- a/obp-api/src/main/scala/code/api/v2_2_0/APIMethods220.scala +++ b/obp-api/src/main/scala/code/api/v2_2_0/APIMethods220.scala @@ -458,7 +458,7 @@ trait APIMethods220 { EmptyBody, messageDocsJson, List(InvalidConnector, UnknownError), - List(apiTagDocumentation, apiTagApi) + List(apiTagMessageDoc, apiTagDocumentation, apiTagApi) ) lazy val getMessageDocs: OBPEndpoint = { diff --git a/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala b/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala index 20a28c5987..719e82a049 100644 --- a/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala +++ b/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala @@ -3157,7 +3157,7 @@ trait APIMethods310 { EmptyBody, EmptyBody, List(UnknownError), - List(apiTagDocumentation, apiTagApi) + List(apiTagMessageDoc, apiTagDocumentation, apiTagApi) ) lazy val getMessageDocsSwagger: OBPEndpoint = { 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 08d294bd42..fe7701e4eb 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 @@ -6365,7 +6365,7 @@ trait APIMethods600 { InvalidConnector, UnknownError ), - List(apiTagDocumentation, apiTagApi) + List(apiTagMessageDoc, apiTagDocumentation, apiTagApi) ) lazy val getMessageDocsJsonSchema: OBPEndpoint = { From 18d1884703837e241eca0a69cacf9845222570d2 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 20 Jan 2026 06:25:25 +0100 Subject: [PATCH 03/25] Docfix: Added note about hasPersonalEntity in Dynamic Entity glossary item. --- .../main/scala/code/api/util/Glossary.scala | 29 +++++++++++++++++++ 1 file changed, 29 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 cde7dd1dd9..2aee4abcb7 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -3152,6 +3152,35 @@ object Glossary extends MdcLoggable { | |OBP generates ONLY the regular endpoints. No 'my' endpoints are created. Use this when the entity represents shared data that should not be user-scoped. | +|**Data Storage Differences:** +| +|Both personal and non-personal entities use the same database table (DynamicData), but the key difference is how user ownership is handled: +| +|When **hasPersonalEntity = true**: +| +|* Each record stores the UserId of the user who created it +|* The UserId is **actively used in all queries** to filter results +|* Users can only see, update, and delete their own records via 'my' endpoints +|* The 'my' endpoints **skip role checks** - user isolation provides the authorization +|* Cascade delete (deleting the entity definition and all data at once) is **not allowed** +| +|When **hasPersonalEntity = false**: +| +|* UserId may be stored for audit purposes but is **ignored in queries** +|* All authorized users see the same shared data +|* Role-based authorization is **required** (e.g., CanGetDynamicEntity_FooBar) +|* Cascade delete **is allowed** - you can delete the entity definition and all its records in one operation +| +|**Summary table:** +| +|| Feature | hasPersonalEntity=true | hasPersonalEntity=false | +||---------|------------------------|-------------------------| +|| Data visibility | Per-user (isolated) | Shared (all users) | +|| UserId in queries | Yes (filters results) | No (ignored) | +|| 'my' endpoints | Generated | Not generated | +|| Authorization | User-scoped (no roles needed for 'my' endpoints) | Role-based | +|| Cascade delete | Blocked | Allowed | +| |**For bank-level entities**, endpoints include the bank ID: | |* POST /banks/BANK_ID/CustomerPreferences From 0e9a69dfe675008430156e0b30d9346b947f326e Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 20 Jan 2026 06:28:28 +0100 Subject: [PATCH 04/25] docfix: hasPersonalEntity flag note --- .../main/scala/code/api/v4_0_0/APIMethods400.scala | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 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 1e52b797a6..123c6390f3 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 @@ -2245,7 +2245,12 @@ trait APIMethods400 extends MdcLoggable { | |This endpoint returns all available reference types (both static OBP entities and dynamic entities) with example values showing the correct format. | - |Note: if you set `hasPersonalEntity` = false, then OBP will not generate the CRUD my FooBar endpoints. + |**The hasPersonalEntity flag:** + | + |* If `hasPersonalEntity` = **true** (default): OBP generates both regular endpoints AND personal 'my' endpoints. Data is user-scoped - each user only sees their own records via 'my' endpoints. + |* If `hasPersonalEntity` = **false**: OBP generates ONLY regular endpoints (no 'my' endpoints). Data is shared - all authorized users see the same records. + | + |This flag also affects authorization (role-based vs user-scoped) and whether cascade delete is allowed. See the Dynamic-Entities glossary for full details on data storage differences. | |$dynamicEntityNamingExplanation | @@ -2448,7 +2453,12 @@ trait APIMethods400 extends MdcLoggable { | |This endpoint returns all available reference types (both static OBP entities and dynamic entities) with example values showing the correct format. | - |Note: if you set `hasPersonalEntity` = false, then OBP will not generate the CRUD my FooBar endpoints. + |**The hasPersonalEntity flag:** + | + |* If `hasPersonalEntity` = **true** (default): OBP generates both regular endpoints AND personal 'my' endpoints. Data is user-scoped - each user only sees their own records via 'my' endpoints. + |* If `hasPersonalEntity` = **false**: OBP generates ONLY regular endpoints (no 'my' endpoints). Data is shared - all authorized users see the same records. + | + |This flag also affects authorization (role-based vs user-scoped) and whether cascade delete is allowed. See the Dynamic-Entities glossary for full details on data storage differences. | |$dynamicEntityNamingExplanation | From 3a77043bfce4c4ba26785d464277eb6f4d2868c5 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 20 Jan 2026 06:36:24 +0100 Subject: [PATCH 05/25] docfix: Dynamic Linking glossary item. --- obp-api/src/main/scala/code/api/util/Glossary.scala | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 2aee4abcb7..acfb6dc2f6 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -3600,7 +3600,17 @@ object Glossary extends MdcLoggable { glossaryItems += GlossaryItem( title = "Dynamic linking (PSD2 context)", description = - s"""""".stripMargin) + s"""Dynamic linking is a security requirement under PSD2's Strong Customer Authentication (SCA) rules. + | + |When a payer initiates an electronic payment transaction, the authentication code must be dynamically linked to: + | + |1. **The amount** of the transaction + |2. **The payee** (recipient) of the transaction + | + |This means if either the amount or payee is modified after authentication, the authentication code becomes invalid. This protects against man-in-the-middle attacks where an attacker might try to redirect funds or change the payment amount after the user has authenticated. + | + |The requirement is specified in Article 97(2) of PSD2 and further detailed in the Regulatory Technical Standards (RTS) on SCA (Articles 5 and 6). + |""".stripMargin) glossaryItems += GlossaryItem( title = "TPP", From 41a1506a66d1d048198dcabd1bd2f6264b19f727 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 20 Jan 2026 07:05:13 +0100 Subject: [PATCH 06/25] Adding challengePurpose, challengeContextHash and challengeContextStructure --- .../transactionChallenge/ChallengeProvider.scala | 4 ++++ .../MappedChallengeProvider.scala | 12 ++++++++++-- .../MappedExpectedChallengeAnswer.scala | 10 ++++++++++ .../commons/model/CommonModelTrait.scala | 10 +++++++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/obp-api/src/main/scala/code/transactionChallenge/ChallengeProvider.scala b/obp-api/src/main/scala/code/transactionChallenge/ChallengeProvider.scala index 53af3e711a..6b31914520 100644 --- a/obp-api/src/main/scala/code/transactionChallenge/ChallengeProvider.scala +++ b/obp-api/src/main/scala/code/transactionChallenge/ChallengeProvider.scala @@ -20,6 +20,10 @@ trait ChallengeProvider { basketId: Option[String], // Note: basketId, consentId and transactionRequestId are exclusive here. authenticationMethodId: Option[String], challengeType: String, + // PSD2 Dynamic Linking fields + challengePurpose: Option[String] = None, // Human-readable description shown to user + challengeContextHash: Option[String] = None, // SHA-256 hash of critical transaction fields + challengeContextStructure: Option[String] = None // Comma-separated list of field names in hash ): Box[ChallengeTrait] def getChallenge(challengeId: String): Box[ChallengeTrait] diff --git a/obp-api/src/main/scala/code/transactionChallenge/MappedChallengeProvider.scala b/obp-api/src/main/scala/code/transactionChallenge/MappedChallengeProvider.scala index 654af20e9f..b9fb7e1542 100644 --- a/obp-api/src/main/scala/code/transactionChallenge/MappedChallengeProvider.scala +++ b/obp-api/src/main/scala/code/transactionChallenge/MappedChallengeProvider.scala @@ -28,8 +28,12 @@ object MappedChallengeProvider extends ChallengeProvider { consentId: Option[String], // Note: consentId and transactionRequestId and basketId are exclusive here. basketId: Option[String], // Note: consentId and transactionRequestId and basketId are exclusive here. authenticationMethodId: Option[String], - challengeType: String, - ): Box[ChallengeTrait] = + challengeType: String, + // PSD2 Dynamic Linking fields + challengePurpose: Option[String] = None, + challengeContextHash: Option[String] = None, + challengeContextStructure: Option[String] = None + ): Box[ChallengeTrait] = tryo ( MappedExpectedChallengeAnswer .create @@ -44,6 +48,10 @@ object MappedChallengeProvider extends ChallengeProvider { .ConsentId(consentId.getOrElse("")) .BasketId(basketId.getOrElse("")) .AuthenticationMethodId(expectedUserId) + // PSD2 Dynamic Linking + .ChallengePurpose(challengePurpose.getOrElse("")) + .ChallengeContextHash(challengeContextHash.getOrElse("")) + .ChallengeContextStructure(challengeContextStructure.getOrElse("")) .saveMe() ) diff --git a/obp-api/src/main/scala/code/transactionChallenge/MappedExpectedChallengeAnswer.scala b/obp-api/src/main/scala/code/transactionChallenge/MappedExpectedChallengeAnswer.scala index 38bbf97dbe..0e27f284a5 100644 --- a/obp-api/src/main/scala/code/transactionChallenge/MappedExpectedChallengeAnswer.scala +++ b/obp-api/src/main/scala/code/transactionChallenge/MappedExpectedChallengeAnswer.scala @@ -29,6 +29,11 @@ class MappedExpectedChallengeAnswer extends ChallengeTrait with LongKeyedMapper[ override def defaultValue = 0 } + // PSD2 Dynamic Linking fields + object ChallengePurpose extends MappedString(this, 2000) + object ChallengeContextHash extends MappedString(this, 64) + object ChallengeContextStructure extends MappedString(this, 500) + override def challengeId: String = ChallengeId.get override def challengeType: String = ChallengeType.get override def transactionRequestId: String = TransactionRequestId.get @@ -42,6 +47,11 @@ class MappedExpectedChallengeAnswer extends ChallengeTrait with LongKeyedMapper[ override def scaStatus: Option[SCAStatus] = Option(StrongCustomerAuthenticationStatus.withName(ScaStatus.get)) override def authenticationMethodId: Option[String] = Option(AuthenticationMethodId.get) override def attemptCounter: Int = AttemptCounter.get + + // PSD2 Dynamic Linking + override def challengePurpose: Option[String] = Option(ChallengePurpose.get).filter(_.nonEmpty) + override def challengeContextHash: Option[String] = Option(ChallengeContextHash.get).filter(_.nonEmpty) + override def challengeContextStructure: Option[String] = Option(ChallengeContextStructure.get).filter(_.nonEmpty) } object MappedExpectedChallengeAnswer extends MappedExpectedChallengeAnswer with LongKeyedMetaMapper[MappedExpectedChallengeAnswer] { diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala index c89720376a..1b7a69b882 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala @@ -647,8 +647,16 @@ trait ChallengeTrait { def scaMethod: Option[SCA] def scaStatus: Option[SCAStatus] def authenticationMethodId: Option[String] - + def attemptCounter: Int + + // PSD2 Dynamic Linking support - these fields ensure the authentication is linked to the transaction details + // challenge_purpose: Human-readable description of what is being authorized (shown to user in SMS/email) + def challengePurpose: Option[String] + // challenge_context_hash: SHA-256 hash of critical transaction fields for tamper detection + def challengeContextHash: Option[String] + // challenge_context_structure: Comma-separated list of field names included in the hash + def challengeContextStructure: Option[String] } From 3f85cb9a02fc71e76c6e6c6452c0cfedc8b4b11f Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 20 Jan 2026 09:40:27 +0100 Subject: [PATCH 07/25] docfix: glossary Response format for GET /mydynamic-entities --- .../main/scala/code/api/util/Glossary.scala | 27 +++++++++++++++++++ 1 file changed, 27 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 acfb6dc2f6..aa23a80adc 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -3283,6 +3283,33 @@ object Glossary extends MdcLoggable { |* GET /my/dynamic-entities - Get all Dynamic Entity definitions I created |* PUT /my/dynamic-entities/DYNAMIC_ENTITY_ID - Update a definition I created | +|**Response format for GET /my/dynamic-entities:** +| +|The response contains an array of dynamic entity definitions. Note that the **entity name is a dynamic key** in each object (not a fixed property name): +| +|```json +|{ +| "dynamic_entities": [ +| { +| "CustomerPreferences": { +| "description": "User preferences", +| "required": ["theme"], +| "properties": { +| "theme": {"type": "string"}, +| "language": {"type": "string"} +| } +| }, +| "dynamicEntityId": "abc-123-def", +| "userId": "user-456", +| "hasPersonalEntity": true, +| "bankId": null +| } +| ] +|} +|``` +| +|**Important:** The entity name (e.g., "CustomerPreferences") appears as a dynamic key, not as a property value. To extract the entity name programmatically, find the key that is NOT one of the standard properties: dynamicEntityId, userId, hasPersonalEntity, bankId. +| |**Required roles:** | |* CanCreateSystemLevelDynamicEntity - To create system level dynamic entities From 79c44db3bcc2c1b8cc4c8229ce76334bdc170eb6 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 20 Jan 2026 10:05:56 +0100 Subject: [PATCH 08/25] Added v6.0.0 GET /my/dynamic-entities with explicit entity_name instead of dynamic key and snake_case --- .../main/scala/code/api/util/Glossary.scala | 34 ++++++++++- .../scala/code/api/v6_0_0/APIMethods600.scala | 56 ++++++++++++++++++- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 53 ++++++++++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) 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 aa23a80adc..2e4512443a 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -3278,14 +3278,42 @@ object Glossary extends MdcLoggable { | |**Note:** If hasPersonalEntity is set to false, no 'my' endpoints are generated. | -|**Management endpoints for Dynamic Entity definitions:** +|**Management endpoints for Dynamic Entity definitions (available from v4.0.0):** | |* GET /my/dynamic-entities - Get all Dynamic Entity definitions I created |* PUT /my/dynamic-entities/DYNAMIC_ENTITY_ID - Update a definition I created | |**Response format for GET /my/dynamic-entities:** | -|The response contains an array of dynamic entity definitions. Note that the **entity name is a dynamic key** in each object (not a fixed property name): +|**v6.0.0 format (recommended):** +| +|The v6.0.0 response uses snake_case field names and an explicit `entity_name` field: +| +|```json +|{ +| "dynamic_entities": [ +| { +| "dynamic_entity_id": "abc-123-def", +| "entity_name": "CustomerPreferences", +| "user_id": "user-456", +| "bank_id": null, +| "has_personal_entity": true, +| "definition": { +| "description": "User preferences", +| "required": ["theme"], +| "properties": { +| "theme": {"type": "string"}, +| "language": {"type": "string"} +| } +| } +| } +| ] +|} +|``` +| +|**v4.0.0 format (legacy):** +| +|The v4.0.0 response uses camelCase field names and the **entity name is a dynamic key** (not a fixed property name): | |```json |{ @@ -3308,7 +3336,7 @@ object Glossary extends MdcLoggable { |} |``` | -|**Important:** The entity name (e.g., "CustomerPreferences") appears as a dynamic key, not as a property value. To extract the entity name programmatically, find the key that is NOT one of the standard properties: dynamicEntityId, userId, hasPersonalEntity, bankId. +|To extract the entity name from the v4.0.0 format programmatically, find the key that is NOT one of the standard properties: dynamicEntityId, userId, hasPersonalEntity, bankId. | |**Required roles:** | 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 fe7701e4eb..12d92f4672 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, CurrentConsumerJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, RedisCacheStatusJsonV600, UpdateAbacRuleJsonV600} +import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CreateAbacRuleJsonV600, CurrentConsumerJsonV600, DynamicEntityDefinitionJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, MyDynamicEntitiesJsonV600, RedisCacheStatusJsonV600, UpdateAbacRuleJsonV600} import code.api.v6_0_0.OBPAPI6_0_0 import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} import code.metrics.APIMetrics @@ -6404,6 +6404,60 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + getMyDynamicEntities, + implementedInApiVersion, + nameOf(getMyDynamicEntities), + "GET", + "/my/dynamic-entities", + "Get My Dynamic Entities", + s"""Get all Dynamic Entity definitions I created. + | + |This v6.0.0 endpoint returns a cleaner response format with: + |* snake_case field names (dynamic_entity_id, user_id, bank_id, has_personal_entity) + |* An explicit entity_name field instead of using the entity name as a dynamic JSON key + |* The entity schema in a separate definition object + | + |For more information see ${Glossary.getGlossaryItemLink( + "My-Dynamic-Entities" + )}""", + EmptyBody, + MyDynamicEntitiesJsonV600( + dynamic_entities = List( + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "CustomerPreferences", + user_id = "user-456", + bank_id = None, + has_personal_entity = true, + definition = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string"}, "language": {"type": "string"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ) + ) + ), + List( + $UserNotLoggedIn, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi) + ) + + lazy val getMyDynamicEntities: OBPEndpoint = { + case "my" :: "dynamic-entities" :: Nil JsonGet req => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + dynamicEntities <- Future( + NewStyle.function.getDynamicEntitiesByUserId(cc.userId) + ) + } yield { + val listCommons: List[DynamicEntityCommons] = dynamicEntities + ( + JSONFactory600.createMyDynamicEntitiesJson(listCommons), + HttpCode.`200`(cc.callContext) + ) + } + } + } + } } 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 55a92ef0f7..3eba87925e 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 @@ -486,6 +486,21 @@ case class AbacPoliciesJsonV600( policies: List[AbacPolicyJsonV600] ) +// Dynamic Entity definition with fully predictable structure (v6.0.0 format) +// No dynamic keys - entity name is an explicit field, schema is in 'definition' +case class DynamicEntityDefinitionJsonV600( + dynamic_entity_id: String, + entity_name: String, + user_id: String, + bank_id: Option[String], + has_personal_entity: Boolean, + definition: net.liftweb.json.JsonAST.JObject +) + +case class MyDynamicEntitiesJsonV600( + dynamic_entities: List[DynamicEntityDefinitionJsonV600] +) + object JSONFactory600 extends CustomJsonFormats with MdcLoggable { def createRedisCallCountersJson( @@ -1294,4 +1309,42 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { redis_available = redisAvailable ) } + + /** + * Create v6.0.0 response for GET /my/dynamic-entities + * + * Fully predictable structure with no dynamic keys. + * Entity name is an explicit field, schema is in 'definition'. + * + * Response format: + * { + * "dynamic_entities": [ + * { + * "dynamic_entity_id": "abc-123", + * "entity_name": "CustomerPreferences", + * "user_id": "user-456", + * "bank_id": null, + * "has_personal_entity": true, + * "definition": { ... schema ... } + * } + * ] + * } + */ + def createMyDynamicEntitiesJson(dynamicEntities: List[code.dynamicEntity.DynamicEntityCommons]): MyDynamicEntitiesJsonV600 = { + import net.liftweb.json.JsonAST._ + import net.liftweb.json.parse + + MyDynamicEntitiesJsonV600( + dynamic_entities = dynamicEntities.map { entity => + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = entity.dynamicEntityId.getOrElse(""), + entity_name = entity.entityName, + user_id = entity.userId, + bank_id = entity.bankId, + has_personal_entity = entity.hasPersonalEntity, + definition = parse(entity.metadataJson).asInstanceOf[JObject] + ) + } + ) + } } From 3f371cf551e33ded41a16fde1a5fed3e72974c7b Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 20 Jan 2026 10:07:08 +0100 Subject: [PATCH 09/25] Adding Message Doc Json schema files --- .../code/api/util/JsonSchemaGenerator.scala | 275 ++++++++++++++++++ .../v6_0_0/MessageDocsJsonSchemaTest.scala | 219 ++++++++++++++ 2 files changed, 494 insertions(+) create mode 100644 obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/MessageDocsJsonSchemaTest.scala diff --git a/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala b/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala new file mode 100644 index 0000000000..781c25ae7d --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala @@ -0,0 +1,275 @@ +package code.api.util + +import code.api.util.APIUtil.MessageDoc +import com.openbankproject.commons.util.ReflectUtils +import net.liftweb.json.JsonAST._ +import net.liftweb.json.JsonDSL._ + +import scala.reflect.runtime.universe._ + +/** + * Utility for generating JSON Schema from Scala case classes + * Used by the message-docs JSON Schema endpoint to provide machine-readable schemas + * for adapter code generation in any language. + */ +object JsonSchemaGenerator { + + /** + * Convert a list of MessageDoc to a complete JSON Schema document + */ + def messageDocsToJsonSchema(messageDocs: List[MessageDoc], connectorName: String): JObject = { + val allDefinitions = scala.collection.mutable.Map[String, JObject]() + + val messages = messageDocs.map { messageDoc => + val outboundType = ReflectUtils.getType(messageDoc.exampleOutboundMessage) + val inboundType = ReflectUtils.getType(messageDoc.exampleInboundMessage) + + // Collect all nested type definitions + collectDefinitions(outboundType, allDefinitions) + collectDefinitions(inboundType, allDefinitions) + + ("process" -> messageDoc.process) ~ + ("description" -> messageDoc.description) ~ + ("message_format" -> messageDoc.messageFormat) ~ + ("outbound_topic" -> messageDoc.outboundTopic) ~ + ("inbound_topic" -> messageDoc.inboundTopic) ~ + ("outbound_schema" -> ( + ("$schema" -> "http://json-schema.org/draft-07/schema#") ~ + typeToJsonSchema(outboundType) + )) ~ + ("inbound_schema" -> ( + ("$schema" -> "http://json-schema.org/draft-07/schema#") ~ + typeToJsonSchema(inboundType) + )) ~ + ("adapter_implementation" -> messageDoc.adapterImplementation.map { impl => + ("group" -> impl.group) ~ + ("suggested_order" -> JInt(BigInt(impl.suggestedOrder))) + }) + } + + ("$schema" -> "http://json-schema.org/draft-07/schema#") ~ + ("title" -> s"$connectorName Message Schemas") ~ + ("description" -> s"JSON Schema definitions for $connectorName connector messages") ~ + ("type" -> "object") ~ + ("properties" -> ( + ("messages" -> ( + ("type" -> "array") ~ + ("items" -> messages) + )) + )) ~ + ("definitions" -> JObject(allDefinitions.toList.map { case (name, schema) => JField(name, schema) })) + } + + /** + * Convert a Scala Type to JSON Schema + */ + private def typeToJsonSchema(tpe: Type): JObject = { + tpe match { + case t if t =:= typeOf[String] => + ("type" -> "string") + + case t if t =:= typeOf[Int] => + ("type" -> "integer") ~ ("format" -> "int32") + + case t if t =:= typeOf[Long] => + ("type" -> "integer") ~ ("format" -> "int64") + + case t if t =:= typeOf[Double] => + ("type" -> "number") ~ ("format" -> "double") + + case t if t =:= typeOf[Float] => + ("type" -> "number") ~ ("format" -> "float") + + case t if t =:= typeOf[BigDecimal] || t =:= typeOf[scala.math.BigDecimal] => + ("type" -> "number") + + case t if t =:= typeOf[Boolean] => + ("type" -> "boolean") + + case t if t =:= typeOf[java.util.Date] => + ("type" -> "string") ~ ("format" -> "date-time") + + case t if t <:< typeOf[Option[_]] => + val innerType = t.typeArgs.head + typeToJsonSchema(innerType) + + case t if t <:< typeOf[List[_]] || t <:< typeOf[Seq[_]] || t <:< typeOf[scala.collection.immutable.List[_]] => + val itemType = t.typeArgs.head + ("type" -> "array") ~ ("items" -> typeToJsonSchema(itemType)) + + case t if t <:< typeOf[Map[_, _]] => + ("type" -> "object") ~ ("additionalProperties" -> typeToJsonSchema(t.typeArgs.last)) + + case t if isEnumType(t) => + val enumValues = getEnumValues(t) + ("type" -> "string") ~ ("enum" -> JArray(enumValues.map(JString(_)))) + + case t if isCaseClass(t) => + val typeName = getTypeName(t) + ("$ref" -> s"#/definitions/$typeName") + + case _ => + // Fallback for unknown types + ("type" -> "object") + } + } + + /** + * Collect all type definitions recursively + */ + private def collectDefinitions(tpe: Type, definitions: scala.collection.mutable.Map[String, JObject]): Unit = { + if (!isCaseClass(tpe) || isPrimitiveOrKnown(tpe)) return + + val typeName = getTypeName(tpe) + if (definitions.contains(typeName)) return + + val schema = caseClassToJsonSchema(tpe, definitions) + definitions += (typeName -> schema) + } + + /** + * Convert a case class to JSON Schema definition + */ + private def caseClassToJsonSchema(tpe: Type, definitions: scala.collection.mutable.Map[String, JObject]): JObject = { + try { + val constructor = ReflectUtils.getPrimaryConstructor(tpe) + val params = constructor.paramLists.flatten + + val properties = params.map { param => + val paramName = param.name.toString + val paramType = param.typeSignature + + // Recursively collect nested definitions + if (isCaseClass(paramType) && !isPrimitiveOrKnown(paramType)) { + collectDefinitions(paramType, definitions) + } + + // Handle List/Seq inner types + if (paramType <:< typeOf[List[_]] || paramType <:< typeOf[Seq[_]]) { + val innerType = paramType.typeArgs.headOption.getOrElse(typeOf[Any]) + if (isCaseClass(innerType) && !isPrimitiveOrKnown(innerType)) { + collectDefinitions(innerType, definitions) + } + } + + // Handle Option inner types + if (paramType <:< typeOf[Option[_]]) { + val innerType = paramType.typeArgs.headOption.getOrElse(typeOf[Any]) + if (isCaseClass(innerType) && !isPrimitiveOrKnown(innerType)) { + collectDefinitions(innerType, definitions) + } + } + + val propertySchema = typeToJsonSchema(paramType) + + // Add description from annotations if available + val description = getFieldDescription(param) + val schemaWithDesc = if (description.nonEmpty) { + propertySchema ~ ("description" -> description) + } else { + propertySchema + } + + JField(paramName, schemaWithDesc) + } + + // Determine required fields (non-Option types) + val requiredFields = params + .filterNot(p => p.typeSignature <:< typeOf[Option[_]]) + .map(_.name.toString) + + val baseSchema = ("type" -> "object") ~ ("properties" -> JObject(properties)) + + if (requiredFields.nonEmpty) { + baseSchema ~ ("required" -> JArray(requiredFields.map(JString(_)))) + } else { + baseSchema + } + } catch { + case e: Exception => + // Fallback for types we can't introspect + ("type" -> "object") ~ ("description" -> s"Schema generation failed: ${e.getMessage}") + } + } + + /** + * Get readable type name for schema definitions + */ + private def getTypeName(tpe: Type): String = { + val fullName = tpe.typeSymbol.fullName + // Remove package prefix, keep only class name + val simpleName = fullName.split("\\.").last + // Handle nested types + simpleName.replace("$", "") + } + + /** + * Check if type is a case class + */ + private def isCaseClass(tpe: Type): Boolean = { + tpe.typeSymbol.isClass && tpe.typeSymbol.asClass.isCaseClass + } + + /** + * Check if type is an enum + */ + private def isEnumType(tpe: Type): Boolean = { + // Check for common enum patterns in OBP + val typeName = tpe.typeSymbol.fullName + typeName.contains("enums.") || + (tpe.baseClasses.exists(_.fullName.contains("Enumeration")) && tpe.typeSymbol.isModuleClass) + } + + /** + * Get enum values if type is an enum + */ + private def getEnumValues(tpe: Type): List[String] = { + try { + // Try to get enum values through reflection + // This is a simplified version - might need enhancement for complex enums + List.empty[String] // Placeholder - enum extraction can be complex + } catch { + case _: Exception => List.empty[String] + } + } + + /** + * Check if type is primitive or commonly known type that shouldn't be expanded + */ + private def isPrimitiveOrKnown(tpe: Type): Boolean = { + tpe =:= typeOf[String] || + tpe =:= typeOf[Int] || + tpe =:= typeOf[Long] || + tpe =:= typeOf[Double] || + tpe =:= typeOf[Float] || + tpe =:= typeOf[Boolean] || + tpe =:= typeOf[BigDecimal] || + tpe =:= typeOf[java.util.Date] || + tpe <:< typeOf[Option[_]] || + tpe <:< typeOf[List[_]] || + tpe <:< typeOf[Seq[_]] || + tpe <:< typeOf[Map[_, _]] + } + + /** + * Extract field description from annotations or scaladoc (simplified) + */ + private def getFieldDescription(param: Symbol): String = { + // This is a placeholder - extracting scaladoc is complex + // Could be enhanced to read annotations or scaladoc comments + "" + } + + /** + * Generate a simplified single-message JSON Schema (for testing) + */ + def generateSchemaForType[T: TypeTag]: JObject = { + val tpe = typeOf[T] + val definitions = scala.collection.mutable.Map[String, JObject]() + collectDefinitions(tpe, definitions) + + ("$schema" -> "http://json-schema.org/draft-07/schema#") ~ + typeToJsonSchema(tpe) ~ + ("definitions" -> JObject(definitions.toList.map { case (name, schema) => JField(name, schema) })) + } +} \ No newline at end of file diff --git a/obp-api/src/test/scala/code/api/v6_0_0/MessageDocsJsonSchemaTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/MessageDocsJsonSchemaTest.scala new file mode 100644 index 0000000000..fd8c99f712 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/MessageDocsJsonSchemaTest.scala @@ -0,0 +1,219 @@ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole +import code.api.util.ErrorMessages.InvalidConnector +import code.api.v6_0_0.OBPAPI6_0_0.Implementations6_0_0 +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.json._ +import org.scalatest.Tag + +class MessageDocsJsonSchemaTest extends V600ServerSetup { + + override def beforeAll(): Unit = { + super.beforeAll() + } + + override def afterAll(): Unit = { + super.afterAll() + } + + /** + * Test tags (for grouping tests) + */ + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint1 extends Tag(nameOf(Implementations6_0_0.getMessageDocsJsonSchema)) + + feature("Get Message Docs as JSON Schema - v6.0.0") { + + scenario("We get JSON Schema for rabbitmq_vOct2024 connector", ApiEndpoint1, VersionOfApi) { + When("We make a request to get message docs as JSON Schema") + val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 200 OK response") + response.code should equal(200) + + And("Response should be valid JSON") + val json = response.body.extract[JValue] + json should not be null + + And("Response should have JSON Schema structure") + val schemaVersion = (json \ "$schema").extractOpt[String] + schemaVersion shouldBe defined + schemaVersion.get should include("json-schema.org") + + And("Response should have a title") + val title = (json \ "title").extractOpt[String] + title shouldBe defined + title.get should include("rabbitmq_vOct2024") + + And("Response should have definitions") + val definitions = (json \ "definitions").extractOpt[JObject] + definitions shouldBe defined + + And("Response should have properties with messages array") + val properties = (json \ "properties").extractOpt[JObject] + properties shouldBe defined + + val messagesProperty = (json \ "properties" \ "messages").extractOpt[JObject] + messagesProperty shouldBe defined + + val messagesType = (json \ "properties" \ "messages" \ "type").extractOpt[String] + messagesType shouldBe Some("array") + + And("Each message should have required structure") + val messages = (json \ "properties" \ "messages" \ "items").extract[List[JValue]] + messages should not be empty + + // Check first message has expected fields + val firstMessage = messages.head + (firstMessage \ "process").extractOpt[String] shouldBe defined + (firstMessage \ "description").extractOpt[String] shouldBe defined + (firstMessage \ "message_format").extractOpt[String] shouldBe defined + (firstMessage \ "outbound_schema").extractOpt[JObject] shouldBe defined + (firstMessage \ "inbound_schema").extractOpt[JObject] shouldBe defined + + And("Outbound schema should be valid JSON Schema") + val outboundSchema = (firstMessage \ "outbound_schema").extract[JObject] + val outboundSchemaVersion = (outboundSchema \ "$schema").extractOpt[String] + outboundSchemaVersion shouldBe defined + + val outboundType = (outboundSchema \ "type").extractOpt[String] + outboundType shouldBe Some("object") + + val outboundProperties = (outboundSchema \ "properties").extractOpt[JObject] + outboundProperties shouldBe defined + + And("Inbound schema should be valid JSON Schema") + val inboundSchema = (firstMessage \ "inbound_schema").extract[JObject] + val inboundSchemaVersion = (inboundSchema \ "$schema").extractOpt[String] + inboundSchemaVersion shouldBe defined + + val inboundType = (inboundSchema \ "type").extractOpt[String] + inboundType shouldBe Some("object") + + val inboundProperties = (inboundSchema \ "properties").extractOpt[JObject] + inboundProperties shouldBe defined + } + + scenario("We get JSON Schema for rest_vMar2019 connector", ApiEndpoint1, VersionOfApi) { + When("We make a request to get message docs as JSON Schema") + val request = (v6_0_0_Request / "message-docs" / "rest_vMar2019" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 200 OK response") + response.code should equal(200) + + And("Response should be valid JSON Schema") + val json = response.body.extract[JValue] + val schemaVersion = (json \ "$schema").extractOpt[String] + schemaVersion shouldBe defined + } + + scenario("We get JSON Schema for akka_vDec2018 connector", ApiEndpoint1, VersionOfApi) { + When("We make a request to get message docs as JSON Schema") + val request = (v6_0_0_Request / "message-docs" / "akka_vDec2018" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 200 OK response") + response.code should equal(200) + + And("Response should be valid JSON Schema") + val json = response.body.extract[JValue] + val schemaVersion = (json \ "$schema").extractOpt[String] + schemaVersion shouldBe defined + } + + scenario("We try to get JSON Schema for invalid connector", ApiEndpoint1, VersionOfApi) { + When("We make a request with invalid connector name") + val request = (v6_0_0_Request / "message-docs" / "invalid_connector" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 400 Bad Request response") + response.code should equal(400) + + And("Error message should mention invalid connector") + val errorMessage = (response.body \ "message").extractOpt[String] + errorMessage shouldBe defined + errorMessage.get should include("InvalidConnector") + } + + scenario("We verify schema includes nested type definitions", ApiEndpoint1, VersionOfApi) { + When("We make a request to get message docs as JSON Schema") + val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 200 OK response") + response.code should equal(200) + + And("Definitions should include common types") + val json = response.body.extract[JValue] + val definitions = (json \ "definitions").extract[JObject] + + // Should have definitions for common adapter types + val definitionNames = definitions.obj.map(_.name) + definitionNames should not be empty + + // Common types that should be present in RabbitMQ schemas + // (exact names depend on the case classes, but there should be several) + definitionNames.length should be > 5 + + And("Each definition should have proper schema structure") + definitions.obj.foreach { case JField(name, schema) => + val schemaObj = schema.asInstanceOf[JObject] + val schemaType = (schemaObj \ "type").extractOpt[String] + schemaType shouldBe Some("object") + + val properties = (schemaObj \ "properties").extractOpt[JObject] + properties shouldBe defined + } + } + + scenario("We verify schema marks required fields correctly", ApiEndpoint1, VersionOfApi) { + When("We make a request to get message docs as JSON Schema") + val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 200 OK response") + response.code should equal(200) + + And("Schemas should indicate required fields") + val json = response.body.extract[JValue] + val messages = (json \ "properties" \ "messages" \ "items").extract[List[JValue]] + + messages.foreach { message => + val outboundSchema = (message \ "outbound_schema").extract[JObject] + val inboundSchema = (message \ "inbound_schema").extract[JObject] + + // Check if required fields are present (they may or may not be required depending on the case class) + val outboundRequired = (outboundSchema \ "required").extractOpt[List[String]] + val inboundRequired = (inboundSchema \ "required").extractOpt[List[String]] + + // At minimum, the structure should be present + outboundSchema should not be null + inboundSchema should not be null + } + } + + scenario("We verify process names match connector method names", ApiEndpoint1, VersionOfApi) { + When("We make a request to get message docs as JSON Schema") + val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 200 OK response") + response.code should equal(200) + + And("Process names should follow obp.methodName pattern") + val json = response.body.extract[JValue] + val messages = (json \ "properties" \ "messages" \ "items").extract[List[JValue]] + + messages.foreach { message => + val process = (message \ "process").extract[String] + process should startWith("obp.") + process.length should be > 4 + } + } + } +} \ No newline at end of file From d48c68fbcf9bbbf8a232f2951615ed8b9ebc2f00 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 20 Jan 2026 10:13:41 +0100 Subject: [PATCH 10/25] added untracked_files --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1f8aabc66e..219ca48427 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ project/project coursier metals.sbt obp-http4s-runner/src/main/resources/git.properties -test-results \ No newline at end of file +test-results +untracked_files/ From ea266cdb59c53837913e1ee0fa271e817634dc3a Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 20 Jan 2026 10:26:29 +0100 Subject: [PATCH 11/25] Fixing new challenge fields --- .../openbankproject/commons/model/CommonModel.scala | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala index c81aac363d..f048d56c78 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala @@ -581,14 +581,17 @@ case class ChallengeCommons( override val expectedUserId : String , override val salt: String , override val successful: Boolean, - + override val challengeType: String, override val consentId: Option[String], override val basketId: Option[String] = None, override val scaMethod: Option[SCA], override val scaStatus: Option[SCAStatus], override val authenticationMethodId: Option[String] , - override val attemptCounter: Int = 0 //NOTE: set the default value here, so do not break current connectors + override val attemptCounter: Int = 0, //NOTE: set the default value here, so do not break current connectors + override val challengePurpose: Option[String] = None, + override val challengeContextHash: Option[String] = None, + override val challengeContextStructure: Option[String] = None ) extends ChallengeTrait object ChallengeCommons extends Converter[ChallengeTrait, ChallengeCommons] @@ -673,7 +676,10 @@ case class ChallengeTraitCommons( scaMethod: Option[SCA], scaStatus: Option[SCAStatus], authenticationMethodId: Option[String], - attemptCounter: Int) extends ChallengeTrait with JsonFieldReName + attemptCounter: Int, + challengePurpose: Option[String] = None, + challengeContextHash: Option[String] = None, + challengeContextStructure: Option[String] = None) extends ChallengeTrait with JsonFieldReName object ChallengeTraitCommons extends Converter[ChallengeTrait, ChallengeTraitCommons] From ceba49c0ea56fcc41e24a9e0f9a868258ff89055 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 20 Jan 2026 15:14:01 +0100 Subject: [PATCH 12/25] Added /personal-dynamic-entities/available --- .../src/main/scala/code/api/util/ApiTag.scala | 1 + .../main/scala/code/api/util/Glossary.scala | 8 ++- .../scala/code/api/v6_0_0/APIMethods600.scala | 58 +++++++++++++++++++ .../code/api/v6_0_0/JSONFactory6.0.0.scala | 1 + 4 files changed, 67 insertions(+), 1 deletion(-) 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 da98f2548f..a3554e474c 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -106,6 +106,7 @@ object ApiTag { val apiTagDynamic = ResourceDocTag("Dynamic") val apiTagDynamicEntity = ResourceDocTag("Dynamic-Entity") val apiTagManageDynamicEntity = ResourceDocTag("Dynamic-Entity-Manage") + val apiTagPersonalDynamicEntity = ResourceDocTag("Personal-Dynamic-Entity") val apiTagDynamicEndpoint = ResourceDocTag("Dynamic-Endpoint") val apiTagManageDynamicEndpoint = ResourceDocTag("Dynamic-Endpoint-Manage") 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 2e4512443a..3c702133aa 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -3283,7 +3283,13 @@ object Glossary extends MdcLoggable { |* GET /my/dynamic-entities - Get all Dynamic Entity definitions I created |* PUT /my/dynamic-entities/DYNAMIC_ENTITY_ID - Update a definition I created | -|**Response format for GET /my/dynamic-entities:** +|**Discovery endpoint (available from v6.0.0):** +| +|* GET /personal-dynamic-entities/available - Discover all Dynamic Entities that support personal data storage +| +|This endpoint allows regular users (without admin roles) to discover which dynamic entities they can interact with for storing personal data via the /my/ENTITY_NAME endpoints. No special roles required - just needs to be logged in. +| +|**Response format for GET /my/dynamic-entities and GET /personal-dynamic-entities/available:** | |**v6.0.0 format (recommended):** | 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 12d92f4672..dbf835eb33 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 @@ -6458,6 +6458,64 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + getAvailablePersonalDynamicEntities, + implementedInApiVersion, + nameOf(getAvailablePersonalDynamicEntities), + "GET", + "/personal-dynamic-entities/available", + "Get Available Personal Dynamic Entities", + s"""Get all Dynamic Entities that support personal data storage (hasPersonalEntity == true). + | + |This endpoint allows regular users (without admin roles) to discover which dynamic entities + |they can interact with for storing personal data via the /my/ENTITY_NAME endpoints. + | + |Authentication: User must be logged in (no special roles required). + | + |Use case: Portals and apps can show users what personal data types are available + |without needing admin access to view all dynamic entity definitions. + | + |For more information see ${Glossary.getGlossaryItemLink("My-Dynamic-Entities")}""", + EmptyBody, + MyDynamicEntitiesJsonV600( + dynamic_entities = List( + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "CustomerPreferences", + user_id = "user-456", + bank_id = None, + has_personal_entity = true, + definition = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string"}, "language": {"type": "string"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ) + ) + ), + List( + $UserNotLoggedIn, + UnknownError + ), + List(apiTagPersonalDynamicEntity, apiTagApi) + ) + + lazy val getAvailablePersonalDynamicEntities: OBPEndpoint = { + case "personal-dynamic-entities" :: "available" :: Nil JsonGet req => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + // Get all dynamic entities (system and bank level) + allDynamicEntities <- Future( + NewStyle.function.getDynamicEntities(None, false) ++ + NewStyle.function.getDynamicEntities(None, true) + ) + } yield { + // Filter to only those with hasPersonalEntity == true + val personalEntities: List[DynamicEntityCommons] = allDynamicEntities.filter(_.hasPersonalEntity) + ( + JSONFactory600.createMyDynamicEntitiesJson(personalEntities), + HttpCode.`200`(cc.callContext) + ) + } + } + } + } } 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 3eba87925e..3c346ca7c7 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 @@ -1347,4 +1347,5 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { } ) } + } From bfa3917ce1634052dd066eeec0d7249013f574a7 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 20 Jan 2026 16:39:18 +0100 Subject: [PATCH 13/25] v6.0.0 of dynmaic endpoints with improved json --- .../scala/code/api/v6_0_0/APIMethods600.scala | 482 ++++++++++++++- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 133 ++++- .../code/api/v6_0_0/DynamicEntityTest.scala | 551 ++++++++++++++++++ 3 files changed, 1158 insertions(+), 8 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala 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 dbf835eb33..b44aeff819 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, CurrentConsumerJsonV600, DynamicEntityDefinitionJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, MyDynamicEntitiesJsonV600, RedisCacheStatusJsonV600, UpdateAbacRuleJsonV600} +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.OBPAPI6_0_0 import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} import code.metrics.APIMetrics @@ -4557,13 +4557,24 @@ trait APIMethods600 { | |Each dynamic entity in the response includes a `record_count` field showing how many data records exist for that entity. | + |This v6.0.0 endpoint returns snake_case field names and an explicit `entity_name` field. + | |For more information see ${Glossary.getGlossaryItemLink( "Dynamic-Entities" )} """, EmptyBody, - ListResult( - "dynamic_entities", - List(dynamicEntityResponseBodyExample) + DynamicEntitiesWithCountJsonV600( + dynamic_entities = List( + DynamicEntityDefinitionWithCountJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "CustomerPreferences", + user_id = "user-456", + bank_id = None, + has_personal_entity = true, + definition = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string"}, "language": {"type": "string"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject], + record_count = 42 + ) + ) ), List( $AuthenticatedUserIsRequired, @@ -4584,16 +4595,82 @@ trait APIMethods600 { ) } yield { val listCommons: List[DynamicEntityCommons] = dynamicEntities.sortBy(_.entityName) - val jObjectsWithCounts = listCommons.map { entity => + val entitiesWithCounts = 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) + (entity, recordCount) + } + ( + JSONFactory600.createDynamicEntitiesWithCountJson(entitiesWithCounts), + HttpCode.`200`(cc.callContext) + ) + } + } + } + + staticResourceDocs += ResourceDoc( + getBankLevelDynamicEntities, + implementedInApiVersion, + nameOf(getBankLevelDynamicEntities), + "GET", + "/management/banks/BANK_ID/dynamic-entities", + "Get Bank Level Dynamic Entities", + s"""Get all Bank Level Dynamic Entities for one bank with record counts. + | + |Each dynamic entity in the response includes a `record_count` field showing how many data records exist for that entity. + | + |This v6.0.0 endpoint returns snake_case field names and an explicit `entity_name` field. + | + |For more information see ${Glossary.getGlossaryItemLink( + "Dynamic-Entities" + )} """, + EmptyBody, + DynamicEntitiesWithCountJsonV600( + dynamic_entities = List( + DynamicEntityDefinitionWithCountJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "CustomerPreferences", + user_id = "user-456", + bank_id = Some("gh.29.uk"), + has_personal_entity = true, + definition = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string"}, "language": {"type": "string"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject], + record_count = 42 + ) + ) + ), + List( + $BankNotFound, + $UserNotLoggedIn, + UserHasMissingRoles, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canGetBankLevelDynamicEntities)) + ) + + lazy val getBankLevelDynamicEntities: OBPEndpoint = { + case "management" :: "banks" :: bankId :: "dynamic-entities" :: Nil JsonGet req => { + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + dynamicEntities <- Future( + NewStyle.function.getDynamicEntities(Some(bankId), false) + ) + } yield { + val listCommons: List[DynamicEntityCommons] = dynamicEntities.sortBy(_.entityName) + val entitiesWithCounts = listCommons.map { entity => + val recordCount = DynamicData.count( + By(DynamicData.DynamicEntityName, entity.entityName), + By(DynamicData.IsPersonalEntity, false), + By(DynamicData.BankId, bankId) + ) + (entity, recordCount) } ( - ListResult("dynamic_entities", jObjectsWithCounts), + JSONFactory600.createDynamicEntitiesWithCountJson(entitiesWithCounts), HttpCode.`200`(cc.callContext) ) } @@ -4614,6 +4691,397 @@ trait APIMethods600 { box.openOrThrowException(s"$UnknownError ") } + // Helper method for creating dynamic entities with v6.0.0 response format + private def createDynamicEntityV600( + cc: CallContext, + dynamicEntity: DynamicEntityCommons + ) = { + for { + Full(result) <- NewStyle.function.createOrUpdateDynamicEntity( + dynamicEntity, + cc.callContext + ) + // Grant the CRUD roles to the logged-in user + crudRoles = List( + DynamicEntityInfo.canCreateRole(result.entityName, dynamicEntity.bankId), + DynamicEntityInfo.canUpdateRole(result.entityName, dynamicEntity.bankId), + DynamicEntityInfo.canGetRole(result.entityName, dynamicEntity.bankId), + DynamicEntityInfo.canDeleteRole(result.entityName, dynamicEntity.bankId) + ) + } yield { + crudRoles.map(role => + Entitlement.entitlement.vend.addEntitlement( + dynamicEntity.bankId.getOrElse(""), + cc.userId, + role.toString() + ) + ) + val commonsData: DynamicEntityCommons = result + ( + JSONFactory600.createMyDynamicEntitiesJson(List(commonsData)).dynamic_entities.head, + HttpCode.`201`(cc.callContext) + ) + } + } + + // Helper method for updating dynamic entities with v6.0.0 response format + private def updateDynamicEntityV600( + cc: CallContext, + dynamicEntity: DynamicEntityCommons + ) = { + for { + Full(result) <- NewStyle.function.createOrUpdateDynamicEntity( + dynamicEntity, + cc.callContext + ) + } yield { + val commonsData: DynamicEntityCommons = result + ( + JSONFactory600.createMyDynamicEntitiesJson(List(commonsData)).dynamic_entities.head, + HttpCode.`200`(cc.callContext) + ) + } + } + + staticResourceDocs += ResourceDoc( + createSystemDynamicEntity, + implementedInApiVersion, + nameOf(createSystemDynamicEntity), + "POST", + "/management/system-dynamic-entities", + "Create System Level Dynamic Entity", + s"""Create a system level Dynamic Entity. + | + |This v6.0.0 endpoint accepts and returns snake_case field names with an explicit `entity_name` field. + | + |**Request format:** + |```json + |{ + | "entity_name": "CustomerPreferences", + | "has_personal_entity": true, + | "definition": { + | "description": "User preferences", + | "required": ["theme"], + | "properties": { + | "theme": {"type": "string", "example": "dark"}, + | "language": {"type": "string", "example": "en"} + | } + | } + |} + |``` + | + |**Important:** Each property MUST include an `example` field with a valid example value. + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + CreateDynamicEntityRequestJsonV600( + entity_name = "CustomerPreferences", + has_personal_entity = Some(true), + definition = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "CustomerPreferences", + user_id = "user-456", + bank_id = None, + has_personal_entity = true, + definition = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + List( + $UserNotLoggedIn, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canCreateSystemLevelDynamicEntity)) + ) + + lazy val createSystemDynamicEntity: OBPEndpoint = { + case "management" :: "system-dynamic-entities" :: Nil JsonPost json -> _ => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[CreateDynamicEntityRequestJsonV600] + } + internalJson = JSONFactory600.convertV600RequestToInternal(request) + dynamicEntity = DynamicEntityCommons(internalJson, None, cc.userId, None) + result <- createDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + createBankLevelDynamicEntity, + implementedInApiVersion, + nameOf(createBankLevelDynamicEntity), + "POST", + "/management/banks/BANK_ID/dynamic-entities", + "Create Bank Level Dynamic Entity", + s"""Create a bank level Dynamic Entity. + | + |This v6.0.0 endpoint accepts and returns snake_case field names with an explicit `entity_name` field. + | + |**Request format:** + |```json + |{ + | "entity_name": "CustomerPreferences", + | "has_personal_entity": true, + | "definition": { + | "description": "User preferences", + | "required": ["theme"], + | "properties": { + | "theme": {"type": "string", "example": "dark"}, + | "language": {"type": "string", "example": "en"} + | } + | } + |} + |``` + | + |**Important:** Each property MUST include an `example` field with a valid example value. + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + CreateDynamicEntityRequestJsonV600( + entity_name = "CustomerPreferences", + has_personal_entity = Some(true), + definition = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "CustomerPreferences", + user_id = "user-456", + bank_id = Some("gh.29.uk"), + has_personal_entity = true, + definition = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + List( + $BankNotFound, + $UserNotLoggedIn, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canCreateBankLevelDynamicEntity)) + ) + + lazy val createBankLevelDynamicEntity: OBPEndpoint = { + case "management" :: "banks" :: bankId :: "dynamic-entities" :: Nil JsonPost json -> _ => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[CreateDynamicEntityRequestJsonV600] + } + internalJson = JSONFactory600.convertV600RequestToInternal(request) + dynamicEntity = DynamicEntityCommons(internalJson, None, cc.userId, Some(bankId)) + result <- createDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + updateSystemDynamicEntity, + implementedInApiVersion, + nameOf(updateSystemDynamicEntity), + "PUT", + "/management/system-dynamic-entities/DYNAMIC_ENTITY_ID", + "Update System Level Dynamic Entity", + s"""Update a system level Dynamic Entity. + | + |This v6.0.0 endpoint accepts and returns snake_case field names with an explicit `entity_name` field. + | + |**Request format:** + |```json + |{ + | "entity_name": "CustomerPreferences", + | "has_personal_entity": true, + | "definition": { + | "description": "User preferences updated", + | "required": ["theme"], + | "properties": { + | "theme": {"type": "string", "example": "dark"}, + | "language": {"type": "string", "example": "en"}, + | "notifications_enabled": {"type": "boolean", "example": "true"} + | } + | } + |} + |``` + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + UpdateDynamicEntityRequestJsonV600( + entity_name = "CustomerPreferences", + has_personal_entity = Some(true), + definition = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "CustomerPreferences", + user_id = "user-456", + bank_id = None, + has_personal_entity = true, + definition = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + List( + $UserNotLoggedIn, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canUpdateSystemDynamicEntity)) + ) + + lazy val updateSystemDynamicEntity: OBPEndpoint = { + case "management" :: "system-dynamic-entities" :: dynamicEntityId :: Nil JsonPut json -> _ => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[UpdateDynamicEntityRequestJsonV600] + } + internalJson = JSONFactory600.convertV600UpdateRequestToInternal(request) + dynamicEntity = DynamicEntityCommons(internalJson, Some(dynamicEntityId), cc.userId, None) + result <- updateDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + updateBankLevelDynamicEntity, + implementedInApiVersion, + nameOf(updateBankLevelDynamicEntity), + "PUT", + "/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID", + "Update Bank Level Dynamic Entity", + s"""Update a bank level Dynamic Entity. + | + |This v6.0.0 endpoint accepts and returns snake_case field names with an explicit `entity_name` field. + | + |**Request format:** + |```json + |{ + | "entity_name": "CustomerPreferences", + | "has_personal_entity": true, + | "definition": { + | "description": "User preferences updated", + | "required": ["theme"], + | "properties": { + | "theme": {"type": "string", "example": "dark"}, + | "language": {"type": "string", "example": "en"}, + | "notifications_enabled": {"type": "boolean", "example": "true"} + | } + | } + |} + |``` + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + UpdateDynamicEntityRequestJsonV600( + entity_name = "CustomerPreferences", + has_personal_entity = Some(true), + definition = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "CustomerPreferences", + user_id = "user-456", + bank_id = Some("gh.29.uk"), + has_personal_entity = true, + definition = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + List( + $BankNotFound, + $UserNotLoggedIn, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canUpdateBankLevelDynamicEntity)) + ) + + lazy val updateBankLevelDynamicEntity: OBPEndpoint = { + case "management" :: "banks" :: bankId :: "dynamic-entities" :: dynamicEntityId :: Nil JsonPut json -> _ => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[UpdateDynamicEntityRequestJsonV600] + } + internalJson = JSONFactory600.convertV600UpdateRequestToInternal(request) + dynamicEntity = DynamicEntityCommons(internalJson, Some(dynamicEntityId), cc.userId, Some(bankId)) + result <- updateDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + updateMyDynamicEntity, + implementedInApiVersion, + nameOf(updateMyDynamicEntity), + "PUT", + "/my/dynamic-entities/DYNAMIC_ENTITY_ID", + "Update My Dynamic Entity", + s"""Update a Dynamic Entity that I created. + | + |This v6.0.0 endpoint accepts and returns snake_case field names with an explicit `entity_name` field. + | + |**Request format:** + |```json + |{ + | "entity_name": "CustomerPreferences", + | "has_personal_entity": true, + | "definition": { + | "description": "User preferences updated", + | "required": ["theme"], + | "properties": { + | "theme": {"type": "string", "example": "dark"}, + | "language": {"type": "string", "example": "en"}, + | "notifications_enabled": {"type": "boolean", "example": "true"} + | } + | } + |} + |``` + | + |For more information see ${Glossary.getGlossaryItemLink("My-Dynamic-Entities")}""", + UpdateDynamicEntityRequestJsonV600( + entity_name = "CustomerPreferences", + has_personal_entity = Some(true), + definition = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "CustomerPreferences", + user_id = "user-456", + bank_id = None, + has_personal_entity = true, + definition = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + List( + $UserNotLoggedIn, + InvalidJsonFormat, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi) + ) + + lazy val updateMyDynamicEntity: OBPEndpoint = { + case "my" :: "dynamic-entities" :: dynamicEntityId :: Nil JsonPut json -> _ => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + // Verify the user owns this dynamic entity + existingEntity <- Future( + NewStyle.function.getDynamicEntitiesByUserId(cc.userId).find(_.dynamicEntityId.contains(dynamicEntityId)) + ) + _ <- Helper.booleanToFuture(s"$DynamicEntityNotFoundByDynamicEntityId dynamicEntityId = $dynamicEntityId", cc = cc.callContext) { + existingEntity.isDefined + } + request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[UpdateDynamicEntityRequestJsonV600] + } + internalJson = JSONFactory600.convertV600UpdateRequestToInternal(request) + dynamicEntity = DynamicEntityCommons(internalJson, Some(dynamicEntityId), cc.userId, existingEntity.get.bankId) + result <- updateDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + staticResourceDocs += ResourceDoc( deleteSystemDynamicEntityCascade, implementedInApiVersion, 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 3c346ca7c7..117fcb4201 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 @@ -501,6 +501,35 @@ case class MyDynamicEntitiesJsonV600( dynamic_entities: List[DynamicEntityDefinitionJsonV600] ) +// Management version includes record_count for admin visibility +case class DynamicEntityDefinitionWithCountJsonV600( + dynamic_entity_id: String, + entity_name: String, + user_id: String, + bank_id: Option[String], + has_personal_entity: Boolean, + definition: net.liftweb.json.JsonAST.JObject, + record_count: Long +) + +case class DynamicEntitiesWithCountJsonV600( + dynamic_entities: List[DynamicEntityDefinitionWithCountJsonV600] +) + +// Request format for creating a dynamic entity (v6.0.0 with snake_case) +case class CreateDynamicEntityRequestJsonV600( + entity_name: String, + has_personal_entity: Option[Boolean], // defaults to true if not provided + definition: net.liftweb.json.JsonAST.JObject +) + +// Request format for updating a dynamic entity (v6.0.0 with snake_case) +case class UpdateDynamicEntityRequestJsonV600( + entity_name: String, + has_personal_entity: Option[Boolean], + definition: net.liftweb.json.JsonAST.JObject +) + object JSONFactory600 extends CustomJsonFormats with MdcLoggable { def createRedisCallCountersJson( @@ -1336,16 +1365,118 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { MyDynamicEntitiesJsonV600( dynamic_entities = dynamicEntities.map { entity => + // metadataJson contains the full internal format: { "EntityName": { schema }, "hasPersonalEntity": true } + // We need to extract just the schema part using the entity name as key + val fullJson = parse(entity.metadataJson).asInstanceOf[JObject] + val schemaOption = fullJson.obj.find(_.name == entity.entityName).map(_.value.asInstanceOf[JObject]) + + // Validate that the dynamic key matches entity_name + val dynamicKeyName = fullJson.obj.find(_.name != "hasPersonalEntity").map(_.name) + if (dynamicKeyName.exists(_ != entity.entityName)) { + throw new IllegalStateException( + s"Dynamic entity key mismatch: stored entityName='${entity.entityName}' but dynamic key='${dynamicKeyName.getOrElse("none")}'" + ) + } + + val schema = schemaOption.getOrElse( + throw new IllegalStateException(s"Could not extract schema for entity '${entity.entityName}' from metadataJson") + ) + DynamicEntityDefinitionJsonV600( dynamic_entity_id = entity.dynamicEntityId.getOrElse(""), entity_name = entity.entityName, user_id = entity.userId, bank_id = entity.bankId, has_personal_entity = entity.hasPersonalEntity, - definition = parse(entity.metadataJson).asInstanceOf[JObject] + definition = schema + ) + } + ) + } + + /** + * Create v6.0.0 response for management GET endpoints (includes record_count) + */ + def createDynamicEntitiesWithCountJson( + entitiesWithCounts: List[(code.dynamicEntity.DynamicEntityCommons, Long)] + ): DynamicEntitiesWithCountJsonV600 = { + import net.liftweb.json.JsonAST._ + import net.liftweb.json.parse + + DynamicEntitiesWithCountJsonV600( + dynamic_entities = entitiesWithCounts.map { case (entity, recordCount) => + // metadataJson contains the full internal format: { "EntityName": { schema }, "hasPersonalEntity": true } + // We need to extract just the schema part using the entity name as key + val fullJson = parse(entity.metadataJson).asInstanceOf[JObject] + val schemaOption = fullJson.obj.find(_.name == entity.entityName).map(_.value.asInstanceOf[JObject]) + + // Validate that the dynamic key matches entity_name + val dynamicKeyName = fullJson.obj.find(_.name != "hasPersonalEntity").map(_.name) + if (dynamicKeyName.exists(_ != entity.entityName)) { + throw new IllegalStateException( + s"Dynamic entity key mismatch: stored entityName='${entity.entityName}' but dynamic key='${dynamicKeyName.getOrElse("none")}'" + ) + } + + val schema = schemaOption.getOrElse( + throw new IllegalStateException(s"Could not extract schema for entity '${entity.entityName}' from metadataJson") + ) + + DynamicEntityDefinitionWithCountJsonV600( + dynamic_entity_id = entity.dynamicEntityId.getOrElse(""), + entity_name = entity.entityName, + user_id = entity.userId, + bank_id = entity.bankId, + has_personal_entity = entity.hasPersonalEntity, + definition = schema, + record_count = recordCount ) } ) } + /** + * Convert v6.0.0 request format to the internal JObject format expected by DynamicEntityCommons.apply + * + * Input (v6.0.0): + * { + * "entity_name": "CustomerPreferences", + * "has_personal_entity": true, + * "definition": { ... schema ... } + * } + * + * Output (internal): + * { + * "CustomerPreferences": { ... schema ... }, + * "hasPersonalEntity": true + * } + */ + def convertV600RequestToInternal(request: CreateDynamicEntityRequestJsonV600): net.liftweb.json.JsonAST.JObject = { + import net.liftweb.json.JsonAST._ + import net.liftweb.json.JsonDSL._ + + val hasPersonalEntity = request.has_personal_entity.getOrElse(true) + + // Build the internal format: entity name as dynamic key + hasPersonalEntity + JObject( + JField(request.entity_name, request.definition) :: + JField("hasPersonalEntity", JBool(hasPersonalEntity)) :: + Nil + ) + } + + def convertV600UpdateRequestToInternal(request: UpdateDynamicEntityRequestJsonV600): net.liftweb.json.JsonAST.JObject = { + import net.liftweb.json.JsonAST._ + import net.liftweb.json.JsonDSL._ + + val hasPersonalEntity = request.has_personal_entity.getOrElse(true) + + // Build the internal format: entity name as dynamic key + hasPersonalEntity + JObject( + JField(request.entity_name, request.definition) :: + JField("hasPersonalEntity", JBool(hasPersonalEntity)) :: + Nil + ) + } + } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala new file mode 100644 index 0000000000..738038bd4c --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala @@ -0,0 +1,551 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2025, 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 com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.json.JsonDSL._ +import net.liftweb.json.Serialization.write +import net.liftweb.json._ +import org.scalatest.Tag + +class DynamicEntityTest extends V600ServerSetup { + + override def beforeAll(): Unit = { + super.beforeAll() + } + + override def afterAll(): Unit = { + super.afterAll() + } + + /** + * 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 ApiEndpoint1 extends Tag(nameOf(Implementations6_0_0.createSystemDynamicEntity)) + object ApiEndpoint2 extends Tag(nameOf(Implementations6_0_0.updateSystemDynamicEntity)) + object ApiEndpoint3 extends Tag(nameOf(Implementations6_0_0.getSystemDynamicEntities)) + object ApiEndpoint4 extends Tag(nameOf(Implementations6_0_0.createBankLevelDynamicEntity)) + object ApiEndpoint5 extends Tag(nameOf(Implementations6_0_0.updateBankLevelDynamicEntity)) + object ApiEndpoint6 extends Tag(nameOf(Implementations6_0_0.getBankLevelDynamicEntities)) + object ApiEndpoint7 extends Tag(nameOf(Implementations6_0_0.getMyDynamicEntities)) + object ApiEndpoint8 extends Tag(nameOf(Implementations6_0_0.updateMyDynamicEntity)) + object ApiEndpoint9 extends Tag(nameOf(Implementations6_0_0.getAvailablePersonalDynamicEntities)) + + lazy val bankId = testBankId1.value + + // v6.0.0 request format with snake_case and explicit entity_name + val rightEntityV600 = parse( + """ + |{ + | "entity_name": "FooBar", + | "has_personal_entity": true, + | "definition": { + | "description": "description of this entity, can be markdown text.", + | "required": [ + | "name" + | ], + | "properties": { + | "name": { + | "type": "string", + | "maxLength": 20, + | "minLength": 3, + | "example": "James Brown", + | "description":"description of **name** field, can be markdown text." + | }, + | "number": { + | "type": "integer", + | "example": 69876172 + | } + | } + | } + |} + |""".stripMargin) + + // Entity with hasPersonalEntity = false + val entityWithoutPersonalV600 = parse( + """ + |{ + | "entity_name": "SharedEntity", + | "has_personal_entity": false, + | "definition": { + | "description": "A shared entity without personal endpoints.", + | "required": [ + | "title" + | ], + | "properties": { + | "title": { + | "type": "string", + | "example": "Some Title" + | } + | } + | } + |} + |""".stripMargin) + + // Wrong format - missing required field + val wrongRequiredEntityV600 = parse( + """ + |{ + | "entity_name": "FooBar", + | "has_personal_entity": true, + | "definition": { + | "description": "description of this entity.", + | "required": [ + | "name_wrong" + | ], + | "properties": { + | "name": { + | "type": "string", + | "example": "James Brown" + | } + | } + | } + |} + |""".stripMargin) + + // Updated entity for PUT tests + val updatedEntityV600 = parse( + """ + |{ + | "entity_name": "FooBar", + | "has_personal_entity": true, + | "definition": { + | "description": "Updated description of this entity.", + | "required": [ + | "name" + | ], + | "properties": { + | "name": { + | "type": "string", + | "maxLength": 30, + | "minLength": 2, + | "example": "Updated Name", + | "description":"Updated description of **name** field." + | }, + | "number": { + | "type": "integer", + | "example": 12345678 + | } + | } + | } + |} + |""".stripMargin) + + + feature("v6.0.0 System Level Dynamic Entity endpoints with snake_case JSON") { + + scenario("Create System Dynamic Entity - without user credentials", ApiEndpoint1, VersionOfApi) { + When(s"We make a POST request without user credentials") + val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST + val response = makePostRequest(request, write(rightEntityV600)) + Then("We should get a 401") + response.code should equal(401) + And("error should be " + UserNotLoggedIn) + response.body.extract[ErrorMessage].message should equal(UserNotLoggedIn) + } + + scenario("Create System Dynamic Entity - without proper role", ApiEndpoint1, VersionOfApi) { + When(s"We make a POST request without the role " + CanCreateSystemLevelDynamicEntity) + val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val response = makePostRequest(request, write(rightEntityV600)) + Then("We should get a 403") + response.code should equal(403) + And("error should contain " + UserHasMissingRoles) + response.body.extract[ErrorMessage].message should include(UserHasMissingRoles) + } + + scenario("Create and verify v6.0.0 snake_case response format", ApiEndpoint1, ApiEndpoint3, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + + When("We create a dynamic entity with v6.0.0 format") + val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val response = makePostRequest(request, write(rightEntityV600)) + + Then("We should get a 201") + response.code should equal(201) + + val responseJson = response.body + + // Verify snake_case field names exist + And("Response should have snake_case field: dynamic_entity_id") + (responseJson \ "dynamic_entity_id") shouldBe a[JString] + + And("Response should have snake_case field: entity_name") + (responseJson \ "entity_name").extract[String] should equal("FooBar") + + And("Response should have snake_case field: user_id") + (responseJson \ "user_id").extract[String] should equal(resourceUser1.userId) + + And("Response should have snake_case field: has_personal_entity") + (responseJson \ "has_personal_entity").extract[Boolean] should equal(true) + + And("Response should have definition field with just the schema (no entity name wrapper)") + val definition = responseJson \ "definition" + (definition \ "description") shouldBe a[JString] + (definition \ "required") shouldBe a[JArray] + (definition \ "properties") shouldBe a[JObject] + + // Verify definition does NOT contain the entity name as a key (old format would have FooBar as key) + And("Definition should NOT contain entity name as a dynamic key") + (definition \ "FooBar") should equal(JNothing) + + val dynamicEntityId = (responseJson \ "dynamic_entity_id").extract[String] + + // Now test GET to verify the response format is consistent + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLevelDynamicEntities.toString) + + When("We GET system dynamic entities") + val getRequest = (v6_0_0_Request / "management" / "system-dynamic-entities").GET <@(user1) + val getResponse = makeGetRequest(getRequest) + + Then("We should get a 200") + getResponse.code should equal(200) + + val entitiesJson = getResponse.body \ "dynamic_entities" + entitiesJson shouldBe a[JArray] + + val entities = entitiesJson.asInstanceOf[JArray].arr + entities should have size 1 + + val entity = entities.head + And("GET response should also use snake_case fields") + (entity \ "dynamic_entity_id").extract[String] should equal(dynamicEntityId) + (entity \ "entity_name").extract[String] should equal("FooBar") + (entity \ "has_personal_entity").extract[Boolean] should equal(true) + + And("GET response should include record_count field") + (entity \ "record_count") shouldBe a[JInt] + + // Cleanup + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) + makeDeleteRequest(deleteRequest) + } + + scenario("Update System Dynamic Entity with v6.0.0 format", ApiEndpoint1, ApiEndpoint2, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanUpdateSystemLevelDynamicEntity.toString) + + // Create first + val createRequest = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val createResponse = makePostRequest(createRequest, write(rightEntityV600)) + createResponse.code should equal(201) + + val dynamicEntityId = (createResponse.body \ "dynamic_entity_id").extract[String] + + When("We update the dynamic entity with v6.0.0 format") + val updateRequest = (v6_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).PUT <@(user1) + val updateResponse = makePutRequest(updateRequest, write(updatedEntityV600)) + + Then("We should get a 200") + updateResponse.code should equal(200) + + val responseJson = updateResponse.body + + And("Updated response should use snake_case fields") + (responseJson \ "dynamic_entity_id").extract[String] should equal(dynamicEntityId) + (responseJson \ "entity_name").extract[String] should equal("FooBar") + + And("Definition should be updated") + val definition = responseJson \ "definition" + (definition \ "description").extract[String] should equal("Updated description of this entity.") + + // Cleanup + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) + makeDeleteRequest(deleteRequest) + } + + scenario("Create Dynamic Entity with invalid schema should fail", ApiEndpoint1, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + + When("We try to create a dynamic entity with wrong required field") + val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val response = makePostRequest(request, write(wrongRequiredEntityV600)) + + Then("We should get a 400") + response.code should equal(400) + + And("Error message should indicate validation failure") + response.body.extract[ErrorMessage].message should include(DynamicEntityInstanceValidateFail) + } + } + + + feature("v6.0.0 Bank Level Dynamic Entity endpoints with snake_case JSON") { + + scenario("Create Bank Level Dynamic Entity - without proper role", ApiEndpoint4, VersionOfApi) { + When(s"We make a POST request without the role " + CanCreateBankLevelDynamicEntity) + val request = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities").POST <@(user1) + val response = makePostRequest(request, write(rightEntityV600)) + Then("We should get a 403") + response.code should equal(403) + } + + scenario("Create and GET Bank Level Dynamic Entity with v6.0.0 format", ApiEndpoint4, ApiEndpoint6, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) + + When("We create a bank level dynamic entity with v6.0.0 format") + val request = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities").POST <@(user1) + val response = makePostRequest(request, write(rightEntityV600)) + + Then("We should get a 201") + response.code should equal(201) + + val responseJson = response.body + + And("Response should have snake_case field: bank_id") + (responseJson \ "bank_id").extract[String] should equal(bankId) + + And("Response should have entity_name") + (responseJson \ "entity_name").extract[String] should equal("FooBar") + + val dynamicEntityId = (responseJson \ "dynamic_entity_id").extract[String] + + // Test GET bank level + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanGetBankLevelDynamicEntities.toString) + + When("We GET bank level dynamic entities") + val getRequest = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities").GET <@(user1) + val getResponse = makeGetRequest(getRequest) + + Then("We should get a 200") + getResponse.code should equal(200) + + val entities = (getResponse.body \ "dynamic_entities").asInstanceOf[JArray].arr + entities should have size 1 + + val entity = entities.head + (entity \ "bank_id").extract[String] should equal(bankId) + (entity \ "entity_name").extract[String] should equal("FooBar") + (entity \ "record_count") shouldBe a[JInt] + + // Cleanup + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanDeleteBankLevelDynamicEntity.toString) + val deleteRequest = (v4_0_0_Request / "management" / "banks" / bankId / "dynamic-entities" / dynamicEntityId).DELETE <@(user1) + makeDeleteRequest(deleteRequest) + } + + scenario("Update Bank Level Dynamic Entity with v6.0.0 format", ApiEndpoint4, ApiEndpoint5, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanUpdateBankLevelDynamicEntity.toString) + + // Create first + val createRequest = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities").POST <@(user1) + val createResponse = makePostRequest(createRequest, write(rightEntityV600)) + createResponse.code should equal(201) + + val dynamicEntityId = (createResponse.body \ "dynamic_entity_id").extract[String] + + When("We update the bank level dynamic entity") + val updateRequest = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities" / dynamicEntityId).PUT <@(user1) + val updateResponse = makePutRequest(updateRequest, write(updatedEntityV600)) + + Then("We should get a 200") + updateResponse.code should equal(200) + + And("Updated response should have snake_case fields") + (updateResponse.body \ "entity_name").extract[String] should equal("FooBar") + (updateResponse.body \ "bank_id").extract[String] should equal(bankId) + + // Cleanup + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanDeleteBankLevelDynamicEntity.toString) + val deleteRequest = (v4_0_0_Request / "management" / "banks" / bankId / "dynamic-entities" / dynamicEntityId).DELETE <@(user1) + makeDeleteRequest(deleteRequest) + } + } + + + feature("v6.0.0 My Dynamic Entities endpoints") { + + scenario("GET My Dynamic Entities - without user credentials", ApiEndpoint7, VersionOfApi) { + When("We make a GET request without user credentials") + val request = (v6_0_0_Request / "my" / "dynamic-entities").GET + val response = makeGetRequest(request) + Then("We should get a 401") + response.code should equal(401) + } + + scenario("GET and Update My Dynamic Entities with v6.0.0 format", ApiEndpoint7, ApiEndpoint8, VersionOfApi) { + // First create a system entity with hasPersonalEntity = true + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + + val createRequest = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val createResponse = makePostRequest(createRequest, write(rightEntityV600)) + createResponse.code should equal(201) + + val dynamicEntityId = (createResponse.body \ "dynamic_entity_id").extract[String] + + When("We GET my dynamic entities") + val getRequest = (v6_0_0_Request / "my" / "dynamic-entities").GET <@(user1) + val getResponse = makeGetRequest(getRequest) + + Then("We should get a 200") + getResponse.code should equal(200) + + val entitiesJson = getResponse.body \ "dynamic_entities" + entitiesJson shouldBe a[JArray] + + val entities = entitiesJson.asInstanceOf[JArray].arr + entities.size should be >= 1 + + And("Response should use snake_case fields") + val entity = entities.find(e => (e \ "entity_name").extract[String] == "FooBar").get + (entity \ "dynamic_entity_id") shouldBe a[JString] + (entity \ "entity_name").extract[String] should equal("FooBar") + (entity \ "user_id").extract[String] should equal(resourceUser1.userId) + (entity \ "has_personal_entity").extract[Boolean] should equal(true) + + And("Definition should contain only the schema") + val definition = entity \ "definition" + (definition \ "description") shouldBe a[JString] + (definition \ "FooBar") should equal(JNothing) // Should NOT have entity name as key + + // Test Update My Dynamic Entity + When("We update my dynamic entity") + val updateRequest = (v6_0_0_Request / "my" / "dynamic-entities" / dynamicEntityId).PUT <@(user1) + val updateResponse = makePutRequest(updateRequest, write(updatedEntityV600)) + + Then("We should get a 200") + updateResponse.code should equal(200) + + And("Updated response should use snake_case fields") + (updateResponse.body \ "entity_name").extract[String] should equal("FooBar") + (updateResponse.body \ "definition" \ "description").extract[String] should equal("Updated description of this entity.") + + // Cleanup + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) + makeDeleteRequest(deleteRequest) + } + } + + + feature("v6.0.0 Available Personal Dynamic Entities discovery endpoint") { + + scenario("GET Available Personal Dynamic Entities - without user credentials", ApiEndpoint9, VersionOfApi) { + When("We make a GET request without user credentials") + val request = (v6_0_0_Request / "personal-dynamic-entities" / "available").GET + val response = makeGetRequest(request) + Then("We should get a 401") + response.code should equal(401) + } + + scenario("GET Available Personal Dynamic Entities returns only entities with hasPersonalEntity=true", ApiEndpoint9, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + + // Create entity WITH hasPersonalEntity = true + val createRequest1 = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val response1 = makePostRequest(createRequest1, write(rightEntityV600)) + response1.code should equal(201) + val entityId1 = (response1.body \ "dynamic_entity_id").extract[String] + + // Create entity WITH hasPersonalEntity = false + val createRequest2 = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val response2 = makePostRequest(createRequest2, write(entityWithoutPersonalV600)) + response2.code should equal(201) + val entityId2 = (response2.body \ "dynamic_entity_id").extract[String] + + When("We GET available personal dynamic entities") + val getRequest = (v6_0_0_Request / "personal-dynamic-entities" / "available").GET <@(user1) + val getResponse = makeGetRequest(getRequest) + + Then("We should get a 200") + getResponse.code should equal(200) + + val entities = (getResponse.body \ "dynamic_entities").asInstanceOf[JArray].arr + + And("Response should contain only entities with has_personal_entity = true") + val entityNames = entities.map(e => (e \ "entity_name").extract[String]) + entityNames should contain("FooBar") + entityNames should not contain("SharedEntity") + + And("All returned entities should have has_personal_entity = true") + entities.foreach { entity => + (entity \ "has_personal_entity").extract[Boolean] should equal(true) + } + + And("Response should use snake_case fields") + entities.foreach { entity => + (entity \ "dynamic_entity_id") shouldBe a[JString] + (entity \ "entity_name") shouldBe a[JString] + (entity \ "definition") shouldBe a[JObject] + } + + // Cleanup + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + val deleteRequest1 = (v4_0_0_Request / "management" / "system-dynamic-entities" / entityId1).DELETE <@(user1) + makeDeleteRequest(deleteRequest1) + val deleteRequest2 = (v4_0_0_Request / "management" / "system-dynamic-entities" / entityId2).DELETE <@(user1) + makeDeleteRequest(deleteRequest2) + } + } + + + feature("v6.0.0 Dynamic Entity definition field validation") { + + scenario("Verify definition contains only schema, not entity name wrapper", ApiEndpoint1, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + + val createRequest = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val createResponse = makePostRequest(createRequest, write(rightEntityV600)) + createResponse.code should equal(201) + + val dynamicEntityId = (createResponse.body \ "dynamic_entity_id").extract[String] + val definition = createResponse.body \ "definition" + + Then("Definition should contain schema fields directly") + (definition \ "description") shouldBe a[JString] + (definition \ "required") shouldBe a[JArray] + (definition \ "properties") shouldBe a[JObject] + + And("Definition should NOT contain the entity name as a nested key (old v4.0.0 format)") + (definition \ "FooBar") should equal(JNothing) + + And("Definition should NOT contain hasPersonalEntity (that's a separate top-level field)") + (definition \ "hasPersonalEntity") should equal(JNothing) + (definition \ "has_personal_entity") should equal(JNothing) + + // Cleanup + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) + makeDeleteRequest(deleteRequest) + } + } + +} From 7fdf8faacc63fe59d07d4abb5c6c0dad1f353a49 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 20 Jan 2026 23:59:19 +0100 Subject: [PATCH 14/25] Dynamic Entity definition to schema --- .../scala/code/api/v6_0_0/APIMethods600.scala | 38 +++++------ .../code/api/v6_0_0/JSONFactory6.0.0.scala | 28 ++++---- .../code/api/v6_0_0/DynamicEntityTest.scala | 66 +++++++++---------- 3 files changed, 66 insertions(+), 66 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 b44aeff819..b8c6afbe0f 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 @@ -4571,7 +4571,7 @@ trait APIMethods600 { user_id = "user-456", bank_id = None, has_personal_entity = true, - definition = 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], record_count = 42 ) ) @@ -4636,7 +4636,7 @@ trait APIMethods600 { user_id = "user-456", bank_id = Some("gh.29.uk"), has_personal_entity = true, - definition = 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], record_count = 42 ) ) @@ -4759,7 +4759,7 @@ trait APIMethods600 { |{ | "entity_name": "CustomerPreferences", | "has_personal_entity": true, - | "definition": { + | "schema": { | "description": "User preferences", | "required": ["theme"], | "properties": { @@ -4776,7 +4776,7 @@ trait APIMethods600 { CreateDynamicEntityRequestJsonV600( entity_name = "CustomerPreferences", has_personal_entity = Some(true), - definition = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", @@ -4784,7 +4784,7 @@ trait APIMethods600 { user_id = "user-456", bank_id = None, has_personal_entity = true, - definition = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), List( $UserNotLoggedIn, @@ -4826,7 +4826,7 @@ trait APIMethods600 { |{ | "entity_name": "CustomerPreferences", | "has_personal_entity": true, - | "definition": { + | "schema": { | "description": "User preferences", | "required": ["theme"], | "properties": { @@ -4843,7 +4843,7 @@ trait APIMethods600 { CreateDynamicEntityRequestJsonV600( entity_name = "CustomerPreferences", has_personal_entity = Some(true), - definition = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", @@ -4851,7 +4851,7 @@ trait APIMethods600 { user_id = "user-456", bank_id = Some("gh.29.uk"), has_personal_entity = true, - definition = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), List( $BankNotFound, @@ -4894,7 +4894,7 @@ trait APIMethods600 { |{ | "entity_name": "CustomerPreferences", | "has_personal_entity": true, - | "definition": { + | "schema": { | "description": "User preferences updated", | "required": ["theme"], | "properties": { @@ -4910,7 +4910,7 @@ trait APIMethods600 { UpdateDynamicEntityRequestJsonV600( entity_name = "CustomerPreferences", has_personal_entity = Some(true), - definition = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", @@ -4918,7 +4918,7 @@ trait APIMethods600 { user_id = "user-456", bank_id = None, has_personal_entity = true, - definition = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), List( $UserNotLoggedIn, @@ -4960,7 +4960,7 @@ trait APIMethods600 { |{ | "entity_name": "CustomerPreferences", | "has_personal_entity": true, - | "definition": { + | "schema": { | "description": "User preferences updated", | "required": ["theme"], | "properties": { @@ -4976,7 +4976,7 @@ trait APIMethods600 { UpdateDynamicEntityRequestJsonV600( entity_name = "CustomerPreferences", has_personal_entity = Some(true), - definition = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", @@ -4984,7 +4984,7 @@ trait APIMethods600 { user_id = "user-456", bank_id = Some("gh.29.uk"), has_personal_entity = true, - definition = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), List( $BankNotFound, @@ -5027,7 +5027,7 @@ trait APIMethods600 { |{ | "entity_name": "CustomerPreferences", | "has_personal_entity": true, - | "definition": { + | "schema": { | "description": "User preferences updated", | "required": ["theme"], | "properties": { @@ -5043,7 +5043,7 @@ trait APIMethods600 { UpdateDynamicEntityRequestJsonV600( entity_name = "CustomerPreferences", has_personal_entity = Some(true), - definition = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", @@ -5051,7 +5051,7 @@ trait APIMethods600 { user_id = "user-456", bank_id = None, has_personal_entity = true, - definition = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), List( $UserNotLoggedIn, @@ -6898,7 +6898,7 @@ trait APIMethods600 { user_id = "user-456", bank_id = None, has_personal_entity = true, - definition = 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] ) ) ), @@ -6953,7 +6953,7 @@ trait APIMethods600 { user_id = "user-456", bank_id = None, has_personal_entity = true, - definition = 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] ) ) ), 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 117fcb4201..47208eade7 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 @@ -487,14 +487,14 @@ case class AbacPoliciesJsonV600( ) // Dynamic Entity definition with fully predictable structure (v6.0.0 format) -// No dynamic keys - entity name is an explicit field, schema is in 'definition' +// No dynamic keys - entity name is an explicit field, schema describes the structure case class DynamicEntityDefinitionJsonV600( dynamic_entity_id: String, entity_name: String, user_id: String, bank_id: Option[String], has_personal_entity: Boolean, - definition: net.liftweb.json.JsonAST.JObject + schema: net.liftweb.json.JsonAST.JObject ) case class MyDynamicEntitiesJsonV600( @@ -508,7 +508,7 @@ case class DynamicEntityDefinitionWithCountJsonV600( user_id: String, bank_id: Option[String], has_personal_entity: Boolean, - definition: net.liftweb.json.JsonAST.JObject, + schema: net.liftweb.json.JsonAST.JObject, record_count: Long ) @@ -520,14 +520,14 @@ case class DynamicEntitiesWithCountJsonV600( case class CreateDynamicEntityRequestJsonV600( entity_name: String, has_personal_entity: Option[Boolean], // defaults to true if not provided - definition: net.liftweb.json.JsonAST.JObject + schema: net.liftweb.json.JsonAST.JObject ) // Request format for updating a dynamic entity (v6.0.0 with snake_case) case class UpdateDynamicEntityRequestJsonV600( entity_name: String, has_personal_entity: Option[Boolean], - definition: net.liftweb.json.JsonAST.JObject + schema: net.liftweb.json.JsonAST.JObject ) object JSONFactory600 extends CustomJsonFormats with MdcLoggable { @@ -1343,7 +1343,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { * Create v6.0.0 response for GET /my/dynamic-entities * * Fully predictable structure with no dynamic keys. - * Entity name is an explicit field, schema is in 'definition'. + * Entity name is an explicit field, schema describes the structure. * * Response format: * { @@ -1354,7 +1354,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { * "user_id": "user-456", * "bank_id": null, * "has_personal_entity": true, - * "definition": { ... schema ... } + * "schema": { ... } * } * ] * } @@ -1378,7 +1378,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ) } - val schema = schemaOption.getOrElse( + val schemaObj = schemaOption.getOrElse( throw new IllegalStateException(s"Could not extract schema for entity '${entity.entityName}' from metadataJson") ) @@ -1388,7 +1388,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { user_id = entity.userId, bank_id = entity.bankId, has_personal_entity = entity.hasPersonalEntity, - definition = schema + schema = schemaObj ) } ) @@ -1418,7 +1418,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ) } - val schema = schemaOption.getOrElse( + val schemaObj = schemaOption.getOrElse( throw new IllegalStateException(s"Could not extract schema for entity '${entity.entityName}' from metadataJson") ) @@ -1428,7 +1428,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { user_id = entity.userId, bank_id = entity.bankId, has_personal_entity = entity.hasPersonalEntity, - definition = schema, + schema = schemaObj, record_count = recordCount ) } @@ -1442,7 +1442,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { * { * "entity_name": "CustomerPreferences", * "has_personal_entity": true, - * "definition": { ... schema ... } + * "schema": { ... } * } * * Output (internal): @@ -1459,7 +1459,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { // Build the internal format: entity name as dynamic key + hasPersonalEntity JObject( - JField(request.entity_name, request.definition) :: + JField(request.entity_name, request.schema) :: JField("hasPersonalEntity", JBool(hasPersonalEntity)) :: Nil ) @@ -1473,7 +1473,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { // Build the internal format: entity name as dynamic key + hasPersonalEntity JObject( - JField(request.entity_name, request.definition) :: + JField(request.entity_name, request.schema) :: JField("hasPersonalEntity", JBool(hasPersonalEntity)) :: Nil ) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala index 738038bd4c..f822fe30d1 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala @@ -74,7 +74,7 @@ class DynamicEntityTest extends V600ServerSetup { |{ | "entity_name": "FooBar", | "has_personal_entity": true, - | "definition": { + | "schema": { | "description": "description of this entity, can be markdown text.", | "required": [ | "name" @@ -102,7 +102,7 @@ class DynamicEntityTest extends V600ServerSetup { |{ | "entity_name": "SharedEntity", | "has_personal_entity": false, - | "definition": { + | "schema": { | "description": "A shared entity without personal endpoints.", | "required": [ | "title" @@ -123,7 +123,7 @@ class DynamicEntityTest extends V600ServerSetup { |{ | "entity_name": "FooBar", | "has_personal_entity": true, - | "definition": { + | "schema": { | "description": "description of this entity.", | "required": [ | "name_wrong" @@ -144,7 +144,7 @@ class DynamicEntityTest extends V600ServerSetup { |{ | "entity_name": "FooBar", | "has_personal_entity": true, - | "definition": { + | "schema": { | "description": "Updated description of this entity.", | "required": [ | "name" @@ -214,15 +214,15 @@ class DynamicEntityTest extends V600ServerSetup { And("Response should have snake_case field: has_personal_entity") (responseJson \ "has_personal_entity").extract[Boolean] should equal(true) - And("Response should have definition field with just the schema (no entity name wrapper)") - val definition = responseJson \ "definition" - (definition \ "description") shouldBe a[JString] - (definition \ "required") shouldBe a[JArray] - (definition \ "properties") shouldBe a[JObject] + And("Response should have schema field with just the schema (no entity name wrapper)") + val schemaField = responseJson \ "schema" + (schemaField \ "description") shouldBe a[JString] + (schemaField \ "required") shouldBe a[JArray] + (schemaField \ "properties") shouldBe a[JObject] - // Verify definition does NOT contain the entity name as a key (old format would have FooBar as key) - And("Definition should NOT contain entity name as a dynamic key") - (definition \ "FooBar") should equal(JNothing) + // Verify schema does NOT contain the entity name as a key (old format would have FooBar as key) + And("Schema should NOT contain entity name as a dynamic key") + (schemaField \ "FooBar") should equal(JNothing) val dynamicEntityId = (responseJson \ "dynamic_entity_id").extract[String] @@ -281,9 +281,9 @@ class DynamicEntityTest extends V600ServerSetup { (responseJson \ "dynamic_entity_id").extract[String] should equal(dynamicEntityId) (responseJson \ "entity_name").extract[String] should equal("FooBar") - And("Definition should be updated") - val definition = responseJson \ "definition" - (definition \ "description").extract[String] should equal("Updated description of this entity.") + And("Schema should be updated") + val schemaField = responseJson \ "schema" + (schemaField \ "description").extract[String] should equal("Updated description of this entity.") // Cleanup Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) @@ -431,10 +431,10 @@ class DynamicEntityTest extends V600ServerSetup { (entity \ "user_id").extract[String] should equal(resourceUser1.userId) (entity \ "has_personal_entity").extract[Boolean] should equal(true) - And("Definition should contain only the schema") - val definition = entity \ "definition" - (definition \ "description") shouldBe a[JString] - (definition \ "FooBar") should equal(JNothing) // Should NOT have entity name as key + And("Schema field should contain only the schema structure") + val schemaField = entity \ "schema" + (schemaField \ "description") shouldBe a[JString] + (schemaField \ "FooBar") should equal(JNothing) // Should NOT have entity name as key // Test Update My Dynamic Entity When("We update my dynamic entity") @@ -446,7 +446,7 @@ class DynamicEntityTest extends V600ServerSetup { And("Updated response should use snake_case fields") (updateResponse.body \ "entity_name").extract[String] should equal("FooBar") - (updateResponse.body \ "definition" \ "description").extract[String] should equal("Updated description of this entity.") + (updateResponse.body \ "schema" \ "description").extract[String] should equal("Updated description of this entity.") // Cleanup Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) @@ -504,7 +504,7 @@ class DynamicEntityTest extends V600ServerSetup { entities.foreach { entity => (entity \ "dynamic_entity_id") shouldBe a[JString] (entity \ "entity_name") shouldBe a[JString] - (entity \ "definition") shouldBe a[JObject] + (entity \ "schema") shouldBe a[JObject] } // Cleanup @@ -517,9 +517,9 @@ class DynamicEntityTest extends V600ServerSetup { } - feature("v6.0.0 Dynamic Entity definition field validation") { + feature("v6.0.0 Dynamic Entity schema field validation") { - scenario("Verify definition contains only schema, not entity name wrapper", ApiEndpoint1, VersionOfApi) { + scenario("Verify schema contains only schema structure, not entity name wrapper", ApiEndpoint1, VersionOfApi) { Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) val createRequest = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) @@ -527,19 +527,19 @@ class DynamicEntityTest extends V600ServerSetup { createResponse.code should equal(201) val dynamicEntityId = (createResponse.body \ "dynamic_entity_id").extract[String] - val definition = createResponse.body \ "definition" + val schemaField = createResponse.body \ "schema" - Then("Definition should contain schema fields directly") - (definition \ "description") shouldBe a[JString] - (definition \ "required") shouldBe a[JArray] - (definition \ "properties") shouldBe a[JObject] + Then("Schema should contain schema fields directly") + (schemaField \ "description") shouldBe a[JString] + (schemaField \ "required") shouldBe a[JArray] + (schemaField \ "properties") shouldBe a[JObject] - And("Definition should NOT contain the entity name as a nested key (old v4.0.0 format)") - (definition \ "FooBar") should equal(JNothing) + And("Schema should NOT contain the entity name as a nested key (old v4.0.0 format)") + (schemaField \ "FooBar") should equal(JNothing) - And("Definition should NOT contain hasPersonalEntity (that's a separate top-level field)") - (definition \ "hasPersonalEntity") should equal(JNothing) - (definition \ "has_personal_entity") should equal(JNothing) + And("Schema should NOT contain hasPersonalEntity (that's a separate top-level field)") + (schemaField \ "hasPersonalEntity") should equal(JNothing) + (schemaField \ "has_personal_entity") should equal(JNothing) // Cleanup Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) From 606f7089e0d311becdc9de0170f32f15dcdc85a8 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 21 Jan 2026 00:52:40 +0100 Subject: [PATCH 15/25] apiTagDynamicEntity --- 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 b8c6afbe0f..c5dd8a6bf9 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 @@ -6961,7 +6961,7 @@ trait APIMethods600 { $UserNotLoggedIn, UnknownError ), - List(apiTagPersonalDynamicEntity, apiTagApi) + List(apiTagDynamicEntity, apiTagPersonalDynamicEntity, apiTagApi) ) lazy val getAvailablePersonalDynamicEntities: OBPEndpoint = { From afa73894c5f0b4e8a2108f4d85217f14660b9978 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 21 Jan 2026 01:44:44 +0100 Subject: [PATCH 16/25] forcing lower_case entity names --- .../scala/code/api/util/ErrorMessages.scala | 1 + .../scala/code/api/v6_0_0/APIMethods600.scala | 68 +++++++++++++------ .../code/api/v6_0_0/DynamicEntityTest.scala | 38 +++++------ 3 files changed, 67 insertions(+), 40 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 572393a371..44fe84e8ef 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -75,6 +75,7 @@ object ErrorMessages { val DynamicDataNotFound = "OBP-09015: Dynamic Data not found. Please specify a valid value." val DuplicateQueryParameters = "OBP-09016: Duplicate Query Parameters are not allowed." val DuplicateHeaderKeys = "OBP-09017: Duplicate Header Keys are not allowed." + val InvalidDynamicEntityName = "OBP-09018: Invalid entity_name format. Entity names must be lowercase with underscores (snake_case), e.g. 'customer_preferences'. No uppercase letters or spaces allowed." // General messages (OBP-10XXX) 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 c5dd8a6bf9..c9bfb4e1ae 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 @@ -4567,7 +4567,7 @@ trait APIMethods600 { dynamic_entities = List( DynamicEntityDefinitionWithCountJsonV600( dynamic_entity_id = "abc-123-def", - entity_name = "CustomerPreferences", + entity_name = "customer_preferences", user_id = "user-456", bank_id = None, has_personal_entity = true, @@ -4632,7 +4632,7 @@ trait APIMethods600 { dynamic_entities = List( DynamicEntityDefinitionWithCountJsonV600( dynamic_entity_id = "abc-123-def", - entity_name = "CustomerPreferences", + entity_name = "customer_preferences", user_id = "user-456", bank_id = Some("gh.29.uk"), has_personal_entity = true, @@ -4757,7 +4757,7 @@ trait APIMethods600 { |**Request format:** |```json |{ - | "entity_name": "CustomerPreferences", + | "entity_name": "customer_preferences", | "has_personal_entity": true, | "schema": { | "description": "User preferences", @@ -4770,17 +4770,19 @@ trait APIMethods600 { |} |``` | - |**Important:** Each property MUST include an `example` field with a valid example value. + |**Important:** + |* The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. + |* Each property MUST include an `example` field with a valid example value. | |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", CreateDynamicEntityRequestJsonV600( - entity_name = "CustomerPreferences", + entity_name = "customer_preferences", has_personal_entity = Some(true), schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", - entity_name = "CustomerPreferences", + entity_name = "customer_preferences", user_id = "user-456", bank_id = None, has_personal_entity = true, @@ -4796,6 +4798,17 @@ trait APIMethods600 { Some(List(canCreateSystemLevelDynamicEntity)) ) + // v6.0.0 entity names must be lowercase with underscores (snake_case) + private val validEntityNamePattern = "^[a-z][a-z0-9_]*$".r.pattern + + private def validateEntityNameV600(entityName: String, callContext: Option[CallContext]): Future[Unit] = { + if (validEntityNamePattern.matcher(entityName).matches()) { + Future.successful(()) + } else { + Future.failed(new RuntimeException(s"$InvalidDynamicEntityName Current value: '$entityName'")) + } + } + lazy val createSystemDynamicEntity: OBPEndpoint = { case "management" :: "system-dynamic-entities" :: Nil JsonPost json -> _ => { cc => implicit val ec = EndpointContext(Some(cc)) @@ -4803,6 +4816,7 @@ trait APIMethods600 { request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { json.extract[CreateDynamicEntityRequestJsonV600] } + _ <- validateEntityNameV600(request.entity_name, cc.callContext) internalJson = JSONFactory600.convertV600RequestToInternal(request) dynamicEntity = DynamicEntityCommons(internalJson, None, cc.userId, None) result <- createDynamicEntityV600(cc, dynamicEntity) @@ -4824,7 +4838,7 @@ trait APIMethods600 { |**Request format:** |```json |{ - | "entity_name": "CustomerPreferences", + | "entity_name": "customer_preferences", | "has_personal_entity": true, | "schema": { | "description": "User preferences", @@ -4837,17 +4851,19 @@ trait APIMethods600 { |} |``` | - |**Important:** Each property MUST include an `example` field with a valid example value. + |**Important:** + |* The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. + |* Each property MUST include an `example` field with a valid example value. | |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", CreateDynamicEntityRequestJsonV600( - entity_name = "CustomerPreferences", + entity_name = "customer_preferences", has_personal_entity = Some(true), schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", - entity_name = "CustomerPreferences", + entity_name = "customer_preferences", user_id = "user-456", bank_id = Some("gh.29.uk"), has_personal_entity = true, @@ -4871,6 +4887,7 @@ trait APIMethods600 { request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { json.extract[CreateDynamicEntityRequestJsonV600] } + _ <- validateEntityNameV600(request.entity_name, cc.callContext) internalJson = JSONFactory600.convertV600RequestToInternal(request) dynamicEntity = DynamicEntityCommons(internalJson, None, cc.userId, Some(bankId)) result <- createDynamicEntityV600(cc, dynamicEntity) @@ -4892,7 +4909,7 @@ trait APIMethods600 { |**Request format:** |```json |{ - | "entity_name": "CustomerPreferences", + | "entity_name": "customer_preferences", | "has_personal_entity": true, | "schema": { | "description": "User preferences updated", @@ -4906,15 +4923,17 @@ trait APIMethods600 { |} |``` | + |**Important:** The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. + | |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", UpdateDynamicEntityRequestJsonV600( - entity_name = "CustomerPreferences", + entity_name = "customer_preferences", has_personal_entity = Some(true), schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", - entity_name = "CustomerPreferences", + entity_name = "customer_preferences", user_id = "user-456", bank_id = None, has_personal_entity = true, @@ -4937,6 +4956,7 @@ trait APIMethods600 { request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { json.extract[UpdateDynamicEntityRequestJsonV600] } + _ <- validateEntityNameV600(request.entity_name, cc.callContext) internalJson = JSONFactory600.convertV600UpdateRequestToInternal(request) dynamicEntity = DynamicEntityCommons(internalJson, Some(dynamicEntityId), cc.userId, None) result <- updateDynamicEntityV600(cc, dynamicEntity) @@ -4958,7 +4978,7 @@ trait APIMethods600 { |**Request format:** |```json |{ - | "entity_name": "CustomerPreferences", + | "entity_name": "customer_preferences", | "has_personal_entity": true, | "schema": { | "description": "User preferences updated", @@ -4972,15 +4992,17 @@ trait APIMethods600 { |} |``` | + |**Important:** The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. + | |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", UpdateDynamicEntityRequestJsonV600( - entity_name = "CustomerPreferences", + entity_name = "customer_preferences", has_personal_entity = Some(true), schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", - entity_name = "CustomerPreferences", + entity_name = "customer_preferences", user_id = "user-456", bank_id = Some("gh.29.uk"), has_personal_entity = true, @@ -5004,6 +5026,7 @@ trait APIMethods600 { request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { json.extract[UpdateDynamicEntityRequestJsonV600] } + _ <- validateEntityNameV600(request.entity_name, cc.callContext) internalJson = JSONFactory600.convertV600UpdateRequestToInternal(request) dynamicEntity = DynamicEntityCommons(internalJson, Some(dynamicEntityId), cc.userId, Some(bankId)) result <- updateDynamicEntityV600(cc, dynamicEntity) @@ -5025,7 +5048,7 @@ trait APIMethods600 { |**Request format:** |```json |{ - | "entity_name": "CustomerPreferences", + | "entity_name": "customer_preferences", | "has_personal_entity": true, | "schema": { | "description": "User preferences updated", @@ -5039,15 +5062,17 @@ trait APIMethods600 { |} |``` | + |**Important:** The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. + | |For more information see ${Glossary.getGlossaryItemLink("My-Dynamic-Entities")}""", UpdateDynamicEntityRequestJsonV600( - entity_name = "CustomerPreferences", + entity_name = "customer_preferences", has_personal_entity = Some(true), schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", - entity_name = "CustomerPreferences", + entity_name = "customer_preferences", user_id = "user-456", bank_id = None, has_personal_entity = true, @@ -5075,6 +5100,7 @@ trait APIMethods600 { request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { json.extract[UpdateDynamicEntityRequestJsonV600] } + _ <- validateEntityNameV600(request.entity_name, cc.callContext) internalJson = JSONFactory600.convertV600UpdateRequestToInternal(request) dynamicEntity = DynamicEntityCommons(internalJson, Some(dynamicEntityId), cc.userId, existingEntity.get.bankId) result <- updateDynamicEntityV600(cc, dynamicEntity) @@ -6894,7 +6920,7 @@ trait APIMethods600 { dynamic_entities = List( DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", - entity_name = "CustomerPreferences", + entity_name = "customer_preferences", user_id = "user-456", bank_id = None, has_personal_entity = true, @@ -6949,7 +6975,7 @@ trait APIMethods600 { dynamic_entities = List( DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", - entity_name = "CustomerPreferences", + entity_name = "customer_preferences", user_id = "user-456", bank_id = None, has_personal_entity = true, diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala index f822fe30d1..d2dcd0a753 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala @@ -72,7 +72,7 @@ class DynamicEntityTest extends V600ServerSetup { val rightEntityV600 = parse( """ |{ - | "entity_name": "FooBar", + | "entity_name": "foo_bar", | "has_personal_entity": true, | "schema": { | "description": "description of this entity, can be markdown text.", @@ -100,7 +100,7 @@ class DynamicEntityTest extends V600ServerSetup { val entityWithoutPersonalV600 = parse( """ |{ - | "entity_name": "SharedEntity", + | "entity_name": "shared_entity", | "has_personal_entity": false, | "schema": { | "description": "A shared entity without personal endpoints.", @@ -121,7 +121,7 @@ class DynamicEntityTest extends V600ServerSetup { val wrongRequiredEntityV600 = parse( """ |{ - | "entity_name": "FooBar", + | "entity_name": "foo_bar", | "has_personal_entity": true, | "schema": { | "description": "description of this entity.", @@ -142,7 +142,7 @@ class DynamicEntityTest extends V600ServerSetup { val updatedEntityV600 = parse( """ |{ - | "entity_name": "FooBar", + | "entity_name": "foo_bar", | "has_personal_entity": true, | "schema": { | "description": "Updated description of this entity.", @@ -206,7 +206,7 @@ class DynamicEntityTest extends V600ServerSetup { (responseJson \ "dynamic_entity_id") shouldBe a[JString] And("Response should have snake_case field: entity_name") - (responseJson \ "entity_name").extract[String] should equal("FooBar") + (responseJson \ "entity_name").extract[String] should equal("foo_bar") And("Response should have snake_case field: user_id") (responseJson \ "user_id").extract[String] should equal(resourceUser1.userId) @@ -220,9 +220,9 @@ class DynamicEntityTest extends V600ServerSetup { (schemaField \ "required") shouldBe a[JArray] (schemaField \ "properties") shouldBe a[JObject] - // Verify schema does NOT contain the entity name as a key (old format would have FooBar as key) + // Verify schema does NOT contain the entity name as a key (old format would have foo_bar as key) And("Schema should NOT contain entity name as a dynamic key") - (schemaField \ "FooBar") should equal(JNothing) + (schemaField \ "foo_bar") should equal(JNothing) val dynamicEntityId = (responseJson \ "dynamic_entity_id").extract[String] @@ -245,7 +245,7 @@ class DynamicEntityTest extends V600ServerSetup { val entity = entities.head And("GET response should also use snake_case fields") (entity \ "dynamic_entity_id").extract[String] should equal(dynamicEntityId) - (entity \ "entity_name").extract[String] should equal("FooBar") + (entity \ "entity_name").extract[String] should equal("foo_bar") (entity \ "has_personal_entity").extract[Boolean] should equal(true) And("GET response should include record_count field") @@ -279,7 +279,7 @@ class DynamicEntityTest extends V600ServerSetup { And("Updated response should use snake_case fields") (responseJson \ "dynamic_entity_id").extract[String] should equal(dynamicEntityId) - (responseJson \ "entity_name").extract[String] should equal("FooBar") + (responseJson \ "entity_name").extract[String] should equal("foo_bar") And("Schema should be updated") val schemaField = responseJson \ "schema" @@ -333,7 +333,7 @@ class DynamicEntityTest extends V600ServerSetup { (responseJson \ "bank_id").extract[String] should equal(bankId) And("Response should have entity_name") - (responseJson \ "entity_name").extract[String] should equal("FooBar") + (responseJson \ "entity_name").extract[String] should equal("foo_bar") val dynamicEntityId = (responseJson \ "dynamic_entity_id").extract[String] @@ -352,7 +352,7 @@ class DynamicEntityTest extends V600ServerSetup { val entity = entities.head (entity \ "bank_id").extract[String] should equal(bankId) - (entity \ "entity_name").extract[String] should equal("FooBar") + (entity \ "entity_name").extract[String] should equal("foo_bar") (entity \ "record_count") shouldBe a[JInt] // Cleanup @@ -380,7 +380,7 @@ class DynamicEntityTest extends V600ServerSetup { updateResponse.code should equal(200) And("Updated response should have snake_case fields") - (updateResponse.body \ "entity_name").extract[String] should equal("FooBar") + (updateResponse.body \ "entity_name").extract[String] should equal("foo_bar") (updateResponse.body \ "bank_id").extract[String] should equal(bankId) // Cleanup @@ -425,16 +425,16 @@ class DynamicEntityTest extends V600ServerSetup { entities.size should be >= 1 And("Response should use snake_case fields") - val entity = entities.find(e => (e \ "entity_name").extract[String] == "FooBar").get + val entity = entities.find(e => (e \ "entity_name").extract[String] == "foo_bar").get (entity \ "dynamic_entity_id") shouldBe a[JString] - (entity \ "entity_name").extract[String] should equal("FooBar") + (entity \ "entity_name").extract[String] should equal("foo_bar") (entity \ "user_id").extract[String] should equal(resourceUser1.userId) (entity \ "has_personal_entity").extract[Boolean] should equal(true) And("Schema field should contain only the schema structure") val schemaField = entity \ "schema" (schemaField \ "description") shouldBe a[JString] - (schemaField \ "FooBar") should equal(JNothing) // Should NOT have entity name as key + (schemaField \ "foo_bar") should equal(JNothing) // Should NOT have entity name as key // Test Update My Dynamic Entity When("We update my dynamic entity") @@ -445,7 +445,7 @@ class DynamicEntityTest extends V600ServerSetup { updateResponse.code should equal(200) And("Updated response should use snake_case fields") - (updateResponse.body \ "entity_name").extract[String] should equal("FooBar") + (updateResponse.body \ "entity_name").extract[String] should equal("foo_bar") (updateResponse.body \ "schema" \ "description").extract[String] should equal("Updated description of this entity.") // Cleanup @@ -492,8 +492,8 @@ class DynamicEntityTest extends V600ServerSetup { And("Response should contain only entities with has_personal_entity = true") val entityNames = entities.map(e => (e \ "entity_name").extract[String]) - entityNames should contain("FooBar") - entityNames should not contain("SharedEntity") + entityNames should contain("foo_bar") + entityNames should not contain("shared_entity") And("All returned entities should have has_personal_entity = true") entities.foreach { entity => @@ -535,7 +535,7 @@ class DynamicEntityTest extends V600ServerSetup { (schemaField \ "properties") shouldBe a[JObject] And("Schema should NOT contain the entity name as a nested key (old v4.0.0 format)") - (schemaField \ "FooBar") should equal(JNothing) + (schemaField \ "foo_bar") should equal(JNothing) And("Schema should NOT contain hasPersonalEntity (that's a separate top-level field)") (schemaField \ "hasPersonalEntity") should equal(JNothing) From c0a0dfed0b103da60d131dcae3c159ad66bf5859 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 21 Jan 2026 01:58:24 +0100 Subject: [PATCH 17/25] fix tricky behaviour with personal dynamic endpoints. /my dynamic entitity endpoints now return records created by user_id even if not via /my endpoints (i.e. if created with another endpoint that requires a role, the record is still yours) --- .../MapppedDynamicDataProvider.scala | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala index b7047e4e30..127d7fb8f8 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala @@ -12,6 +12,13 @@ import net.liftweb.mapper._ import net.liftweb.util.Helpers.tryo import org.apache.commons.lang3.StringUtils +/** + * Note on IsPersonalEntity flag: + * The IsPersonalEntity flag indicates HOW a record was created (via /my/ endpoint or not), + * but is NOT used as a filter when querying personal data. The /my/ endpoints return all + * records belonging to the current user (filtered by UserId), regardless of IsPersonalEntity value. + * This provides a unified view of a user's data whether it was created via /my/ or non-/my/ endpoints. + */ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonFormats{ override def save(bankId: Option[String], entityName: String, requestBody: JObject, userId: Option[String], isPersonalEntity: Boolean): Box[DynamicDataT] = { val idName = getIdName(entityName) @@ -40,7 +47,7 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm 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. DynamicData.find( - By(DynamicData.DynamicDataId, id), + By(DynamicData.DynamicDataId, id), By(DynamicData.DynamicEntityName, entityName), By(DynamicData.UserId, userId.getOrElse(null)), By(DynamicData.IsPersonalEntity, false), @@ -49,12 +56,11 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm case Full(dynamicData) => Full(dynamicData) case _ => Failure(s"$DynamicDataNotFound dynamicEntityName=$entityName, dynamicDataId=$id") } - } else if(bankId.isEmpty && isPersonalEntity){ //isPersonalEntity == true, get all the data for specific userId. + } else if(bankId.isEmpty && isPersonalEntity){ //isPersonalEntity == true, get the data for specific userId (regardless of how it was created). DynamicData.find( - By(DynamicData.DynamicDataId, id), + By(DynamicData.DynamicDataId, id), By(DynamicData.DynamicEntityName, entityName), By(DynamicData.UserId, userId.getOrElse(null)), - By(DynamicData.IsPersonalEntity, true), NullRef(DynamicData.BankId) ) match { case Full(dynamicData) => Full(dynamicData) @@ -63,7 +69,7 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm } else if(bankId.isDefined && !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. DynamicData.find( - By(DynamicData.DynamicDataId, id), + By(DynamicData.DynamicDataId, id), By(DynamicData.DynamicEntityName, entityName), By(DynamicData.IsPersonalEntity, false), By(DynamicData.BankId, bankId.get), @@ -71,19 +77,18 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm case Full(dynamicData) => Full(dynamicData) case _ => Failure(s"$DynamicDataNotFound dynamicEntityName=$entityName, dynamicDataId=$id, bankId= ${bankId.get}") } - }else{ //isPersonalEntity == true, get all the data for specific userId. + }else{ //isPersonalEntity == true, get the data for specific userId (regardless of how it was created). DynamicData.find( - By(DynamicData.DynamicDataId, id), + By(DynamicData.DynamicDataId, id), By(DynamicData.DynamicEntityName, entityName), By(DynamicData.BankId, bankId.get), - By(DynamicData.UserId, userId.get), - By(DynamicData.IsPersonalEntity, true) + By(DynamicData.UserId, userId.get) ) match { case Full(dynamicData) => Full(dynamicData) case _ => Failure(s"$DynamicDataNotFound dynamicEntityName=$entityName, dynamicDataId=$id, bankId= ${bankId.get}, userId = ${userId.get}") } } - + } override def getAllDataJson(bankId: Option[String], entityName: String, userId: Option[String], isPersonalEntity: Boolean): List[JObject] = { @@ -98,14 +103,13 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm By(DynamicData.DynamicEntityName, entityName), By(DynamicData.IsPersonalEntity, false), NullRef(DynamicData.BankId), - ) - } else if(bankId.isEmpty && isPersonalEntity){ //isPersonalEntity == true, get all the data for specific userId. + ) + } else if(bankId.isEmpty && isPersonalEntity){ //isPersonalEntity == true, get all the data for specific userId (regardless of how it was created). DynamicData.findAll( By(DynamicData.DynamicEntityName, entityName), By(DynamicData.UserId, userId.getOrElse(null)), - By(DynamicData.IsPersonalEntity, true), NullRef(DynamicData.BankId) - ) + ) } else if(bankId.isDefined && !isPersonalEntity){ //isPersonalEntity == false, get all the data, no need for specific userId. DynamicData.findAll( By(DynamicData.DynamicEntityName, entityName), @@ -113,11 +117,10 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm By(DynamicData.BankId, bankId.get), ) }else{ - DynamicData.findAll(//isPersonalEntity == true, get all the data for specific userId. + DynamicData.findAll(//isPersonalEntity == true, get all the data for specific userId (regardless of how it was created). By(DynamicData.DynamicEntityName, entityName), By(DynamicData.BankId, bankId.get), - By(DynamicData.UserId, userId.getOrElse(null)), - By(DynamicData.IsPersonalEntity, true) + By(DynamicData.UserId, userId.getOrElse(null)) ) } } @@ -139,18 +142,16 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm By(DynamicData.BankId, bankId.get), By(DynamicData.IsPersonalEntity, false) ).nonEmpty - } else if(bankId.isEmpty && isPersonalEntity){ //isPersonalEntity == true, get all the data for specific userId. + } else if(bankId.isEmpty && isPersonalEntity){ //isPersonalEntity == true, check if data exists for specific userId (regardless of how it was created). DynamicData.find( By(DynamicData.DynamicEntityName, dynamicEntityName), NullRef(DynamicData.BankId), - By(DynamicData.IsPersonalEntity, true), By(DynamicData.UserId, userId.getOrElse(null)) ).nonEmpty - } else { + } else { //isPersonalEntity == true, check if data exists for specific userId (regardless of how it was created). DynamicData.find( By(DynamicData.DynamicEntityName, dynamicEntityName), By(DynamicData.BankId, bankId.get), - By(DynamicData.IsPersonalEntity, true), By(DynamicData.UserId, userId.getOrElse(null)) ).nonEmpty } From aaf04ee036c0f3669b0e1addfb48d33526a10c1b Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 21 Jan 2026 09:42:47 +0100 Subject: [PATCH 18/25] fixtest: GET my dynamic entity endpoint gets records the user created, not just records with hasPersonalEntity=true. Previous behaviour was confusing. --- .../code/api/v4_0_0/DynamicEntityTest.scala | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicEntityTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicEntityTest.scala index cdb6cbf6f2..a26a284ddf 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicEntityTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicEntityTest.scala @@ -1725,20 +1725,20 @@ class DynamicEntityTest extends V400ServerSetup { } { - Then("User1 get my foobar, only return his own records, only one") + Then("User1 get my foobar, returns his own records (including those created via non-/my endpoints)") val requestGetFoobars = (dynamicEntity_Request / "my" / "FooBar").GET <@ (user1) val responseGetFoobars = makeGetRequest(requestGetFoobars) responseGetFoobars.code should equal(200) - (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(1) + (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(2) } { - Then("User2 get my foobar, only return his own records, only one") + Then("User2 get my foobar, returns his own records (including those created via non-/my endpoints)") val requestGetFoobars = (dynamicEntity_Request / "my" / "FooBar").GET <@ (user2) val responseGetFoobars = makeGetRequest(requestGetFoobars) responseGetFoobars.code should equal(200) - (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(1) + (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(2) } { @@ -1851,20 +1851,20 @@ class DynamicEntityTest extends V400ServerSetup { } { - Then("User1 get my foobar, only return his own records, only one") + Then("User1 get my foobar, returns his own records (including those created via non-/my endpoints)") val requestGetFoobars = (dynamicEntity_Request / "banks" / testBankId1.value / "my" / "FooBar").GET <@ (user1) val responseGetFoobars = makeGetRequest(requestGetFoobars) responseGetFoobars.code should equal(200) - (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(1) + (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(2) } { - Then("User2 get my foobar, only return his own records, only one") + Then("User2 get my foobar, returns his own records (including those created via non-/my endpoints)") val requestGetFoobars = (dynamicEntity_Request / "banks" / testBankId1.value / "my" / "FooBar").GET <@ (user2) val responseGetFoobars = makeGetRequest(requestGetFoobars) responseGetFoobars.code should equal(200) - (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(1) + (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(2) } { From 7a27d3ac0d5710fbaf3fae1d4efce9aa670e31e9 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 21 Jan 2026 10:10:26 +0100 Subject: [PATCH 19/25] Schema Validation tests --- .../code/api/util/JsonSchemaGenerator.scala | 10 +- .../v6_0_0/MessageDocsJsonSchemaTest.scala | 104 ++++++++++++++---- 2 files changed, 84 insertions(+), 30 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala b/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala index 781c25ae7d..6e095fb989 100644 --- a/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala +++ b/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala @@ -33,14 +33,8 @@ object JsonSchemaGenerator { ("message_format" -> messageDoc.messageFormat) ~ ("outbound_topic" -> messageDoc.outboundTopic) ~ ("inbound_topic" -> messageDoc.inboundTopic) ~ - ("outbound_schema" -> ( - ("$schema" -> "http://json-schema.org/draft-07/schema#") ~ - typeToJsonSchema(outboundType) - )) ~ - ("inbound_schema" -> ( - ("$schema" -> "http://json-schema.org/draft-07/schema#") ~ - typeToJsonSchema(inboundType) - )) ~ + ("outbound_schema" -> typeToJsonSchema(outboundType)) ~ + ("inbound_schema" -> typeToJsonSchema(inboundType)) ~ ("adapter_implementation" -> messageDoc.adapterImplementation.map { impl => ("group" -> impl.group) ~ ("suggested_order" -> JInt(BigInt(impl.suggestedOrder))) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/MessageDocsJsonSchemaTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/MessageDocsJsonSchemaTest.scala index fd8c99f712..5f6443ab7b 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/MessageDocsJsonSchemaTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/MessageDocsJsonSchemaTest.scala @@ -4,13 +4,36 @@ import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole import code.api.util.ErrorMessages.InvalidConnector import code.api.v6_0_0.OBPAPI6_0_0.Implementations6_0_0 +import com.fasterxml.jackson.databind.ObjectMapper import com.github.dwickern.macros.NameOf.nameOf +import com.networknt.schema.{JsonSchemaFactory, SpecVersion} import com.openbankproject.commons.util.ApiVersion import net.liftweb.json._ import org.scalatest.Tag +/** + * Tests for the Message Docs JSON Schema endpoint (v6.0.0) + * + * This endpoint returns message documentation as JSON Schema format for code generation. + * The schema follows JSON Schema draft-07 specification and is validated using the + * networknt/json-schema-validator library (https://github.com/networknt/json-schema-validator). + * + * Schema structure: + * - Root level: $schema, title, description, type, properties, definitions + * - Each message includes: process, description, outbound_schema, inbound_schema + * - Type definitions use $ref references to the definitions section + * - All definitions have: type: "object", properties, required (for non-Option fields) + * + * Industry Standard Compliance: + * - Validated against JSON Schema draft-07 meta-schema + * - Uses standard $ref for type references + * - Compatible with code generation tools like quicktype + */ class MessageDocsJsonSchemaTest extends V600ServerSetup { - + + // Jackson ObjectMapper for converting between Lift JSON and Jackson JsonNode + private val mapper = new ObjectMapper() + override def beforeAll(): Unit = { super.beforeAll() } @@ -77,25 +100,17 @@ class MessageDocsJsonSchemaTest extends V600ServerSetup { And("Outbound schema should be valid JSON Schema") val outboundSchema = (firstMessage \ "outbound_schema").extract[JObject] - val outboundSchemaVersion = (outboundSchema \ "$schema").extractOpt[String] - outboundSchemaVersion shouldBe defined - + // Schema can have either a direct "type" or a "$ref" to definitions for case classes val outboundType = (outboundSchema \ "type").extractOpt[String] - outboundType shouldBe Some("object") - - val outboundProperties = (outboundSchema \ "properties").extractOpt[JObject] - outboundProperties shouldBe defined - + val outboundRef = (outboundSchema \ "$ref").extractOpt[String] + (outboundType.isDefined || outboundRef.isDefined) shouldBe true + And("Inbound schema should be valid JSON Schema") val inboundSchema = (firstMessage \ "inbound_schema").extract[JObject] - val inboundSchemaVersion = (inboundSchema \ "$schema").extractOpt[String] - inboundSchemaVersion shouldBe defined - + // Schema can have either a direct "type" or a "$ref" to definitions for case classes val inboundType = (inboundSchema \ "type").extractOpt[String] - inboundType shouldBe Some("object") - - val inboundProperties = (inboundSchema \ "properties").extractOpt[JObject] - inboundProperties shouldBe defined + val inboundRef = (inboundSchema \ "$ref").extractOpt[String] + (inboundType.isDefined || inboundRef.isDefined) shouldBe true } scenario("We get JSON Schema for rest_vMar2019 connector", ApiEndpoint1, VersionOfApi) { @@ -130,14 +145,14 @@ class MessageDocsJsonSchemaTest extends V600ServerSetup { When("We make a request with invalid connector name") val request = (v6_0_0_Request / "message-docs" / "invalid_connector" / "json-schema").GET val response = makeGetRequest(request) - + Then("We should get a 400 Bad Request response") response.code should equal(400) - + And("Error message should mention invalid connector") val errorMessage = (response.body \ "message").extractOpt[String] errorMessage shouldBe defined - errorMessage.get should include("InvalidConnector") + errorMessage.get should include("Invalid Connector") } scenario("We verify schema includes nested type definitions", ApiEndpoint1, VersionOfApi) { @@ -201,19 +216,64 @@ class MessageDocsJsonSchemaTest extends V600ServerSetup { When("We make a request to get message docs as JSON Schema") val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET val response = makeGetRequest(request) - + Then("We should get a 200 OK response") response.code should equal(200) - + And("Process names should follow obp.methodName pattern") val json = response.body.extract[JValue] val messages = (json \ "properties" \ "messages" \ "items").extract[List[JValue]] - + messages.foreach { message => val process = (message \ "process").extract[String] process should startWith("obp.") process.length should be > 4 } } + + scenario("We validate schema is industry-standard JSON Schema draft-07 using networknt validator", ApiEndpoint1, VersionOfApi) { + When("We make a request to get message docs as JSON Schema") + val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 200 OK response") + response.code should equal(200) + + And("Schema should be valid according to JSON Schema draft-07 specification") + val schemaString = compactRender(response.body) + val schemaNode = mapper.readTree(schemaString) + + // Use networknt JSON Schema validator with draft-07 + val factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7) + val jsonSchema = factory.getSchema(schemaNode) + + // The schema should load without errors (this validates the schema structure) + jsonSchema should not be null + + And("Schema should have valid definitions that can be resolved") + val definitions = (response.body \ "definitions").extract[JObject] + definitions.obj.length should be > 100 // Should have many type definitions + + And("Each definition should be valid JSON Schema") + definitions.obj.foreach { case JField(name, defn) => + val defnString = compactRender(defn) + val defnNode = mapper.readTree(defnString) + // Create a schema from each definition to validate it + val defnSchema = factory.getSchema(defnNode) + defnSchema should not be null + } + + And("$ref references should resolve correctly within the schema") + val messages = (response.body \ "properties" \ "messages" \ "items").extract[List[JValue]] + val firstMessage = messages.head + val outboundRef = (firstMessage \ "outbound_schema" \ "$ref").extractOpt[String] + outboundRef shouldBe defined + outboundRef.get should startWith("#/definitions/") + + // Extract the referenced definition name and verify it exists + val refName = outboundRef.get.replace("#/definitions/", "") + val definitionNames = definitions.obj.map(_.name) + definitionNames should contain(refName) + } } } \ No newline at end of file From bb4e082160d509dc7f182ce0feea5c4dbfd805ba Mon Sep 17 00:00:00 2001 From: karmaking Date: Wed, 21 Jan 2026 11:59:36 +0100 Subject: [PATCH 20/25] add context lines to build failures --- .github/workflows/build_container_develop_branch.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_container_develop_branch.yml b/.github/workflows/build_container_develop_branch.yml index f2905252ae..72f94bb144 100644 --- a/.github/workflows/build_container_develop_branch.yml +++ b/.github/workflows/build_container_develop_branch.yml @@ -86,7 +86,7 @@ jobs: echo "No maven-build.log found; skipping failure scan." exit 0 fi - if grep -n "\*\*\* FAILED \*\*\*" maven-build.log; then + if grep -C 3 -n "\*\*\* FAILED \*\*\*" maven-build.log; then echo "Failing tests detected above." exit 1 else From 191f867fd28ff42d8c4543c5937d29cb9a26758a Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 21 Jan 2026 12:28:46 +0100 Subject: [PATCH 21/25] docfix: Added Connector.User.Authentication --- .../main/scala/code/api/util/Glossary.scala | 114 +++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) 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 3c702133aa..fbab12e006 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -578,7 +578,119 @@ object Glossary extends MdcLoggable { |""" ) - + glossaryItems += GlossaryItem( + title = "Connector.User.Authentication", + description = + s""" + |### Overview + | + |The property `connector.user.authentication` (default: `false`) controls whether OBP can authenticate a user via the Connector when they are not found locally. + | + |OBP always checks for users locally first. When this property is enabled and a user is not found locally (or exists but is from an external provider), OBP will attempt to authenticate them against an external identity provider or Core Banking System (CBS) via the Connector. + | + |### Configuration + | + |In your props file: + | + |``` + |connector.user.authentication=true + |``` + | + |### Behavior When Enabled (true) + | + |**1. Login Authentication Flow:** + | + |When a user attempts to log in: + | + |``` + |User Login Request + | │ + | ▼ + |┌─────────────────────────┐ + |│ 1. Check if user exists │ + |│ locally in OBP │ + |└───────────┬─────────────┘ + | │ + | ┌────────┼────────┬─────────────────┐ + | │ │ │ │ + | ▼ ▼ ▼ ▼ + |Found Found Found Not Found + |(local (external (external (and property + |provider) provider) provider enabled) + | │ property property │ + | │ disabled) enabled) │ + | │ │ │ │ + | ▼ ▼ ▼ ▼ + |┌────────┐ ┌────┐ ┌─────────────────────────┐ + |│Check │ │Fail│ │ 2. Call Connector: │ + |│local │ │ │ │ checkExternalUser │ + |│password│ │ │ │ Credentials() │ + |└───┬────┘ └────┘ └───────────┬─────────────┘ + | │ │ + | ▼ ┌────────┴────────┐ + | Success/ │ │ + | Failure ▼ ▼ + | Success Failure + | │ │ + | ▼ ▼ + | ┌─────────────┐ ┌─────────────┐ + | │Create local │ │Increment │ + | │AuthUser if │ │bad login │ + | │not exists │ │attempts │ + | └─────────────┘ └─────────────┘ + |``` + | + |**2. Username Uniqueness Validation:** + | + |During user signup, OBP checks if the username already exists in the external system by calling `checkExternalUserExists()`. + | + |**3. Auto Creation of Local Users:** + | + |If external authentication succeeds but the user doesn't exist locally, OBP automatically creates a local `AuthUser` record linked to the external provider. + | + |### Behavior When Disabled (false, default) + | + |* Users must exist locally in OBP's database + |* Authentication is performed against locally stored credentials + |* No connector calls are made for authentication + | + |### Required Connector Methods + | + |When enabled, your Connector must implement: + | + |* ${messageDocLinkRabbitMQ("obp.checkExternalUserCredentials")} : Validates username and password against external system. Returns `InboundExternalUser` with user details (sub, iss, email, name, userAuthContexts). + | + |* ${messageDocLinkRabbitMQ("obp.checkExternalUserExists")} : Checks if a username exists in the external system. Used during signup validation. + | + |### InboundExternalUser Response + | + |The connector should return user information including: + | + |* `sub`: Subject identifier (username) + |* `iss`: Issuer (provider identifier) + |* `email`: User's email address + |* `name`: User's display name + |* `userAuthContexts`: Optional list of auth contexts (e.g., customer numbers) + | + |### Use Cases + | + |**Enable when:** + |* You have an external identity provider (LDAP, Active Directory, OAuth provider) + |* User credentials are managed by the Core Banking System + |* You want single sign on with an existing user directory + | + |**Disable when:** + |* OBP manages all user authentication locally + |* You're using OBP's built in user management + |* You don't have an external authentication system + | + |### Related Properties + | + |* `connector`: Specifies which connector implementation to use + |* `connector.user.authcontext.read.in.login`: Read user auth contexts during login + | + |""" + ) From 0b63bfcae31dfb8d75c4cd2efddc3a0e3d43611a Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 21 Jan 2026 16:00:47 +0100 Subject: [PATCH 22/25] Test isolation --- .../test/scala/code/setup/ServerSetup.scala | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/obp-api/src/test/scala/code/setup/ServerSetup.scala b/obp-api/src/test/scala/code/setup/ServerSetup.scala index f6acfc8c1a..7993c0db1c 100644 --- a/obp-api/src/test/scala/code/setup/ServerSetup.scala +++ b/obp-api/src/test/scala/code/setup/ServerSetup.scala @@ -28,14 +28,18 @@ TESOBE (http://www.tesobe.com/) package code.setup import _root_.net.liftweb.json.JsonAST.JObject +import bootstrap.liftweb.ToSchemify import code.TestServer import code.api.util.APIUtil._ import code.api.util.{APIUtil, CustomJsonFormats} +import code.model.{Consumer, Nonce, Token} +import code.model.dataAccess.{AuthUser, ResourceUser} import code.util.Helper.MdcLoggable import com.openbankproject.commons.model.{AccountId, BankId} import dispatch._ import net.liftweb.common.{Empty, Full} import net.liftweb.json.JsonDSL._ +import net.liftweb.mapper.MetaMapper import org.scalatest._ trait ServerSetup extends FeatureSpec with SendServerRequests @@ -64,7 +68,42 @@ trait ServerSetup extends FeatureSpec with SendServerRequests // This prevents conflicts when both RunWebApp and tests are running System.setProperty("pekko.remote.artery.canonical.port", "0") System.setProperty("pekko.remote.artery.bind.port", "0") - + + /** + * Reset database before each test class to ensure test isolation. + * + * This prevents test pollution where state from one test class leaks into another. + * All tests share a single TestServer/database instance, so we need to clean up + * before each test class starts. + * + * We preserve only the essential OAuth/auth tables (Nonce, Token, Consumer, AuthUser, ResourceUser) + * as these are needed for test authentication and are managed by DefaultUsers trait. + */ + override def beforeAll(): Unit = { + super.beforeAll() + resetDatabaseForTestClass() + } + + /** + * Resets database tables to ensure clean state for each test class. + * Preserves auth-related tables that are managed separately by DefaultUsers. + */ + protected def resetDatabaseForTestClass(): Unit = { + def exclusion(m: MetaMapper[_]): Boolean = { + m == Nonce || m == Token || m == Consumer || m == AuthUser || m == ResourceUser + } + + logger.info(s"[TEST ISOLATION] Resetting database before test class: ${this.getClass.getSimpleName}") + ToSchemify.models.filterNot(exclusion).foreach { model => + try { + model.bulkDelete_!!() + } catch { + case e: Exception => + logger.warn(s"[TEST ISOLATION] Failed to clear table for ${model.getClass.getSimpleName}: ${e.getMessage}") + } + } + } + val server = TestServer def baseRequest = host(server.host, server.port) val secured = APIUtil.getPropsAsBoolValue("external.https", false) From b5282b4568d4e0d48646f625d65e4c6a5bfc73d1 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 20 Jan 2026 16:39:18 +0100 Subject: [PATCH 23/25] fixing merge rebase v6.0.0 of dynmaic endpoints with improved json --- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) 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 47208eade7..adf3865c15 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 @@ -1394,6 +1394,47 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ) } + /** + * Create v6.0.0 response for management GET endpoints (includes record_count) + */ + def createDynamicEntitiesWithCountJson( + entitiesWithCounts: List[(code.dynamicEntity.DynamicEntityCommons, Long)] + ): DynamicEntitiesWithCountJsonV600 = { + import net.liftweb.json.JsonAST._ + import net.liftweb.json.parse + + DynamicEntitiesWithCountJsonV600( + dynamic_entities = entitiesWithCounts.map { case (entity, recordCount) => + // metadataJson contains the full internal format: { "EntityName": { schema }, "hasPersonalEntity": true } + // We need to extract just the schema part using the entity name as key + val fullJson = parse(entity.metadataJson).asInstanceOf[JObject] + val schemaOption = fullJson.obj.find(_.name == entity.entityName).map(_.value.asInstanceOf[JObject]) + + // Validate that the dynamic key matches entity_name + val dynamicKeyName = fullJson.obj.find(_.name != "hasPersonalEntity").map(_.name) + if (dynamicKeyName.exists(_ != entity.entityName)) { + throw new IllegalStateException( + s"Dynamic entity key mismatch: stored entityName='${entity.entityName}' but dynamic key='${dynamicKeyName.getOrElse("none")}'" + ) + } + + val schema = schemaOption.getOrElse( + throw new IllegalStateException(s"Could not extract schema for entity '${entity.entityName}' from metadataJson") + ) + + DynamicEntityDefinitionWithCountJsonV600( + dynamic_entity_id = entity.dynamicEntityId.getOrElse(""), + entity_name = entity.entityName, + user_id = entity.userId, + bank_id = entity.bankId, + has_personal_entity = entity.hasPersonalEntity, + definition = schema, + record_count = recordCount + ) + } + ) + } + /** * Create v6.0.0 response for management GET endpoints (includes record_count) */ From 34623632285485498c4e0bb5b49063ff64ec2a13 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 22 Jan 2026 06:51:14 +0100 Subject: [PATCH 24/25] rebasefix: fixing errors introduced during rebase merging --- .../scala/code/api/v6_0_0/APIMethods600.scala | 16 +++---- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 43 +------------------ .../code/api/v6_0_0/DynamicEntityTest.scala | 4 +- 3 files changed, 11 insertions(+), 52 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 c9bfb4e1ae..6f436a0233 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 @@ -4643,7 +4643,7 @@ trait APIMethods600 { ), List( $BankNotFound, - $UserNotLoggedIn, + $AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError ), @@ -4789,7 +4789,7 @@ trait APIMethods600 { schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), List( - $UserNotLoggedIn, + $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError @@ -4871,7 +4871,7 @@ trait APIMethods600 { ), List( $BankNotFound, - $UserNotLoggedIn, + $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError @@ -4940,7 +4940,7 @@ trait APIMethods600 { schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), List( - $UserNotLoggedIn, + $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError @@ -5010,7 +5010,7 @@ trait APIMethods600 { ), List( $BankNotFound, - $UserNotLoggedIn, + $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError @@ -5079,7 +5079,7 @@ trait APIMethods600 { schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), List( - $UserNotLoggedIn, + $AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError ), @@ -6929,7 +6929,7 @@ trait APIMethods600 { ) ), List( - $UserNotLoggedIn, + $AuthenticatedUserIsRequired, UnknownError ), List(apiTagManageDynamicEntity, apiTagApi) @@ -6984,7 +6984,7 @@ trait APIMethods600 { ) ), List( - $UserNotLoggedIn, + $AuthenticatedUserIsRequired, UnknownError ), List(apiTagDynamicEntity, apiTagPersonalDynamicEntity, apiTagApi) 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 adf3865c15..fed14e065a 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 @@ -1428,48 +1428,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { user_id = entity.userId, bank_id = entity.bankId, has_personal_entity = entity.hasPersonalEntity, - definition = schema, - record_count = recordCount - ) - } - ) - } - - /** - * Create v6.0.0 response for management GET endpoints (includes record_count) - */ - def createDynamicEntitiesWithCountJson( - entitiesWithCounts: List[(code.dynamicEntity.DynamicEntityCommons, Long)] - ): DynamicEntitiesWithCountJsonV600 = { - import net.liftweb.json.JsonAST._ - import net.liftweb.json.parse - - DynamicEntitiesWithCountJsonV600( - dynamic_entities = entitiesWithCounts.map { case (entity, recordCount) => - // metadataJson contains the full internal format: { "EntityName": { schema }, "hasPersonalEntity": true } - // We need to extract just the schema part using the entity name as key - val fullJson = parse(entity.metadataJson).asInstanceOf[JObject] - val schemaOption = fullJson.obj.find(_.name == entity.entityName).map(_.value.asInstanceOf[JObject]) - - // Validate that the dynamic key matches entity_name - val dynamicKeyName = fullJson.obj.find(_.name != "hasPersonalEntity").map(_.name) - if (dynamicKeyName.exists(_ != entity.entityName)) { - throw new IllegalStateException( - s"Dynamic entity key mismatch: stored entityName='${entity.entityName}' but dynamic key='${dynamicKeyName.getOrElse("none")}'" - ) - } - - val schemaObj = schemaOption.getOrElse( - throw new IllegalStateException(s"Could not extract schema for entity '${entity.entityName}' from metadataJson") - ) - - DynamicEntityDefinitionWithCountJsonV600( - 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 = schema, record_count = recordCount ) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala index d2dcd0a753..f4e402eca5 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala @@ -175,8 +175,8 @@ class DynamicEntityTest extends V600ServerSetup { val response = makePostRequest(request, write(rightEntityV600)) Then("We should get a 401") response.code should equal(401) - And("error should be " + UserNotLoggedIn) - response.body.extract[ErrorMessage].message should equal(UserNotLoggedIn) + And("error should be " + AuthenticatedUserIsRequired) + response.body.extract[ErrorMessage].message should equal(AuthenticatedUserIsRequired) } scenario("Create System Dynamic Entity - without proper role", ApiEndpoint1, VersionOfApi) { From 6466c8e9f705a754c7abf2c6e4af33be4ded51e6 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 22 Jan 2026 11:05:47 +0100 Subject: [PATCH 25/25] Updating frozen metadata for Challenge Commons (added 3 new fields, not a breaking change as long as Adapters are lenient on extra fields) --- .../RestConnector_vMar2019_frozen_meta_data | Bin 123950 -> 124071 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/obp-api/src/test/scala/code/connector/RestConnector_vMar2019_frozen_meta_data b/obp-api/src/test/scala/code/connector/RestConnector_vMar2019_frozen_meta_data index ffdf640c9c70fc3370b5af47b5710df002a7869b..4d255759f9411dc344c39a85b1c1fb1b10b5c0f1 100644 GIT binary patch delta 127 zcmZ2?f_?c(_J%Et3ws#_rZ4JcoGK`goROH5lbV;F8c