diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index c487692034..4e4390e4ae 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -110,7 +110,7 @@ import code.productAttributeattribute.MappedProductAttribute import code.productcollection.MappedProductCollection import code.productcollectionitem.MappedProductCollectionItem import code.productfee.ProductFee -import code.products.MappedProduct +import code.products.{MappedProduct, ProductTag} import code.ratelimiting.RateLimiting import code.regulatedentities.MappedRegulatedEntity import code.regulatedentities.attribute.RegulatedEntityAttribute @@ -543,6 +543,7 @@ class Boot extends MdcLoggable { // If set to true we will write each URL with params to a datastore / log file if (APIUtil.getPropsAsBoolValue("write_connector_metrics", false)) { logger.info("writeConnectorMetrics is true. We will write connector metrics") + code.metrics.ConnectorMetricBatchWriter.start() } else { logger.info("writeConnectorMetrics is false. We will NOT write connector metrics") } @@ -1149,6 +1150,7 @@ object ToSchemify { DynamicMessageDoc, EndpointTag, ProductFee, + ProductTag, ViewPermission, UserInitAction, CounterpartyLimit, diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index a4bd7c8409..dc0c29ef0e 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -5344,7 +5344,8 @@ object SwaggerDefinitionsJSON { per_hour_call_limit = Some(1000L), per_day_call_limit = Some(10000L), per_week_call_limit = Some(50000L), - per_month_call_limit = Some(200000L) + per_month_call_limit = Some(200000L), + tags = Some(List("featured", "beta")) ) lazy val apiProductJsonV600 = ApiProductJsonV600( api_product_id = "api-product-id-123", @@ -5365,10 +5366,28 @@ object SwaggerDefinitionsJSON { per_day_call_limit = 10000L, per_week_call_limit = 50000L, per_month_call_limit = 200000L, + tags = List("featured", "beta"), attributes = Some(List(apiProductAttributeResponseJsonV600)) ) lazy val apiProductsJsonV600 = ApiProductsJsonV600(List(apiProductJsonV600)) + lazy val productJsonV600 = ProductJsonV600( + bank_id = bankIdExample.value, + product_code = productCodeExample.value, + parent_product_code = "parent", + name = "product name", + more_info_url = "www.example.com/prod1/more-info.html", + terms_and_conditions_url = "www.example.com/prod1/terms.html", + description = "Description", + meta = metaJson, + tags = List("featured", "new"), + attributes = None, + fees = None + ) + lazy val productsJsonV600 = ProductsJsonV600(products = List(productJsonV600)) + + lazy val productTagsJsonV600 = ProductTagsJsonV600(tags = List("featured", "new")) + lazy val featuresJsonV600 = FeaturesJsonV600( allow_public_views = true, allow_abac_account_access = false, diff --git a/obp-api/src/main/scala/code/api/cache/Caching.scala b/obp-api/src/main/scala/code/api/cache/Caching.scala index 413d1e7007..4a87556ce3 100644 --- a/obp-api/src/main/scala/code/api/cache/Caching.scala +++ b/obp-api/src/main/scala/code/api/cache/Caching.scala @@ -88,6 +88,29 @@ object Caching extends MdcLoggable { def setStaticSwaggerDocCache(key:String, value: String)= { use(JedisMethod.SET, (STATIC_SWAGGER_DOC_CACHE_KEY_PREFIX+key).intern(), Some(GET_STATIC_RESOURCE_DOCS_TTL), Some(value)) } + // Fail-safe wrappers around Redis.use for product caches. If Redis is unreachable (dev without a + // running Redis, transient failure, etc.) we treat it as a miss and recompute instead of failing + // the whole request. + private def tryGet(prefix: String, key: String, ttlSeconds: Int): Option[String] = + try use(JedisMethod.GET, (prefix + key).intern(), Some(ttlSeconds)) + catch { case e: Throwable => logger.debug(s"Cache GET failed for $prefix$key: ${e.getMessage}"); None } + + private def trySet(prefix: String, key: String, ttlSeconds: Int, value: String): Unit = + try { use(JedisMethod.SET, (prefix + key).intern(), Some(ttlSeconds), Some(value)); () } + catch { case e: Throwable => logger.debug(s"Cache SET failed for $prefix$key: ${e.getMessage}") } + + def getFinancialProductsCache(key: String, ttlSeconds: Int): Option[String] = + tryGet(FINANCIAL_PRODUCTS_PREFIX, key, ttlSeconds) + + def setFinancialProductsCache(key: String, value: String, ttlSeconds: Int): Unit = + trySet(FINANCIAL_PRODUCTS_PREFIX, key, ttlSeconds, value) + + def getApiProductsCache(key: String, ttlSeconds: Int): Option[String] = + tryGet(API_PRODUCTS_PREFIX, key, ttlSeconds) + + def setApiProductsCache(key: String, value: String, ttlSeconds: Int): Unit = + trySet(API_PRODUCTS_PREFIX, key, ttlSeconds, value) + /** * Invalidate all rate limit cache entries for a specific consumer. * Uses pattern matching to delete all cache keys with prefix: rl_active_{consumerId}_* diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index f4881e15fe..7cdcd86276 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -223,6 +223,8 @@ object Constant extends MdcLoggable { final val ABAC_RULE_NAMESPACE = "abac_rule" final val CONNECTOR_OUTBOUND_NAMESPACE = "connector_outbound" final val CONNECTOR_INBOUND_NAMESPACE = "connector_inbound" + final val FINANCIAL_PRODUCTS_NAMESPACE = "financial_products" + final val API_PRODUCTS_NAMESPACE = "api_products" // List of all versioned cache namespaces final val ALL_CACHE_NAMESPACES = List( @@ -238,7 +240,9 @@ object Constant extends MdcLoggable { METRICS_RECENT_NAMESPACE, ABAC_RULE_NAMESPACE, CONNECTOR_OUTBOUND_NAMESPACE, - CONNECTOR_INBOUND_NAMESPACE + CONNECTOR_INBOUND_NAMESPACE, + FINANCIAL_PRODUCTS_NAMESPACE, + API_PRODUCTS_NAMESPACE ) // Cache key prefixes with global namespace and versioning for easy invalidation @@ -274,6 +278,11 @@ object Constant extends MdcLoggable { def CONNECTOR_OUTBOUND_PREFIX: String = getVersionedCachePrefix(CONNECTOR_OUTBOUND_NAMESPACE) def CONNECTOR_INBOUND_PREFIX: String = getVersionedCachePrefix(CONNECTOR_INBOUND_NAMESPACE) + // Financial Product list cache (bank-scoped and all-banks share this namespace). + def FINANCIAL_PRODUCTS_PREFIX: String = getVersionedCachePrefix(FINANCIAL_PRODUCTS_NAMESPACE) + // Api Product list cache. + def API_PRODUCTS_PREFIX: String = getVersionedCachePrefix(API_PRODUCTS_NAMESPACE) + // ABAC Policy Constants final val ABAC_POLICY_ACCOUNT_ACCESS = "account-access" diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index 213771b7e7..226809337f 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -408,6 +408,12 @@ object ApiRole extends MdcLoggable{ case class CanCreateProductAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canCreateProductAtAnyBank = CanCreateProductAtAnyBank() + case class CanUpdateProductTagsAtOneBank(requiresBankId: Boolean = true) extends ApiRole + lazy val canUpdateProductTagsAtOneBank = CanUpdateProductTagsAtOneBank() + + case class CanUpdateProductTagsAtAnyBank(requiresBankId: Boolean = false) extends ApiRole + lazy val canUpdateProductTagsAtAnyBank = CanUpdateProductTagsAtAnyBank() + case class CanCreateFxRate(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateFxRate = CanCreateFxRate() 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 7842c09b44..e560866f73 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -769,18 +769,18 @@ object ErrorMessages { val ConnectorMethodNotFound = "OBP-40036: ConnectorMethod not found, please specify valid CONNECTOR_METHOD_ID. " val ConnectorMethodAlreadyExists = "OBP-40037: ConnectorMethod already exists. " - val ConnectorMethodBodyCompileFail = "OBP-40038: ConnectorMethod methodBody is illegal scala code, compilation failed. " - val DynamicResourceDocAlreadyExists = "OBP-40039: DynamicResourceDoc already exists." + val ConnectorMethodBodyCompileFail = "OBP-40038: ConnectorMethod methodBody is not valid Scala code, compilation failed. " + val DynamicResourceDocAlreadyExists = "OBP-40039: DynamicResourceDoc already exists. " val DynamicResourceDocNotFound = "OBP-40040: DynamicResourceDoc not found, please specify valid DYNAMIC_RESOURCE_DOC_ID. " - val DynamicResourceDocDeleteError = "OBP-40041: DynamicResourceDoc can not be deleted. " + val DynamicResourceDocDeleteError = "OBP-40041: DynamicResourceDoc could not be deleted. " - val DynamicMessageDocAlreadyExists = "OBP-40042: DynamicMessageDoc already exists." + val DynamicMessageDocAlreadyExists = "OBP-40042: DynamicMessageDoc already exists. " val DynamicMessageDocNotFound = "OBP-40043: DynamicMessageDoc not found, please specify valid DYNAMIC_MESSAGE_DOC_ID. " - val DynamicMessageDocDeleteError = "OBP-40044: DynamicMessageDoc can not be deleted. " - val DynamicCodeCompileFail = "OBP-40045: The code to do compile is illegal scala code, compilation failed. " + val DynamicMessageDocDeleteError = "OBP-40044: DynamicMessageDoc could not be deleted. " + val DynamicCodeCompileFail = "OBP-40045: The code to compile is not valid Scala code, compilation failed. " - val DynamicResourceDocMethodDependency = "OBP-40046: DynamicResourceDoc method call forbidden methods. " - val DynamicResourceDocMethodPermission = "OBP-40047: DynamicResourceDoc method have no enough permissions. " + val DynamicResourceDocMethodDependency = "OBP-40046: DynamicResourceDoc method calls a forbidden method. " + val DynamicResourceDocMethodPermission = "OBP-40047: DynamicResourceDoc method does not have sufficient permissions. " val DynamicCodeLangNotSupport = "OBP-40049: This language of dynamic code is not supported. " val InvalidOperationId = "OBP-40048: Invalid operation_id, please specify valid operation_id." 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 9dcdd92db9..ca4d0ab5b1 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -3282,6 +3282,24 @@ object Glossary extends MdcLoggable { |*Create a Guard with a named Role on the Endpoint to protect it from unauthorised users. |*Grant you an Entitlement to the required Role so you can call the endpoint and pass its Guard. | +|### Served URL +| +|Dynamic Endpoints are served under a dedicated path prefix, *not* under `/obp/vX.Y.Z/`: +| +|`/obp/dynamic-endpoint` + optional `dynamic_endpoints_url_prefix` (from props) + the path declared in the uploaded Swagger file. +| +|For example, if your Swagger declares `/fashion-brand-list/{brandId}` and `dynamic_endpoints_url_prefix` is unset (the default), the endpoint will be available at: +| +|`/obp/dynamic-endpoint/fashion-brand-list/{brandId}` +| +|For Bank / Space level Dynamic Endpoints, OBP automatically prepends `/banks/BANK_ID` to each path in the Swagger file at creation time. So a Swagger path of `/fashion-brand-list` created for bank `gh.29.uk` is served at: +| +|`/obp/dynamic-endpoint/banks/gh.29.uk/fashion-brand-list` +| +|(plus `dynamic_endpoints_url_prefix` if set.) +| +|Note: the `/obp/vX.Y.Z/management/banks/BANK_ID/dynamic-endpoints` routes are only the administrative CRUD endpoints for creating and managing Dynamic Endpoints — they are not the served URLs of the endpoints themselves. +| |The following videos are available: | | * [Introduction to Dynamic Endpoints](https://vimeo.com/426235612) @@ -3302,6 +3320,50 @@ object Glossary extends MdcLoggable { |Once the `host` is thus set, you can use the Endpoint Mapping endpoints to map the Dynamic Endpoint fields to Dynamic Entity data. | |See the [Create Endpoint Mapping](/index#OBPv4.0.0-createEndpointMapping) JSON body. You will need to know the operation_id in advance and you can prepare the request_mapping and response_mapping objects. You can get the operation ID from the API Explorer or Get Dynamic Endpoints endpoints. + | + |### Mapping structure + | + |Each entry in `request_mapping` / `response_mapping` is keyed by the JSON field name you want in the Dynamic Endpoint's request or response payload, and its value is an object of the form: + | + |``` + |"": { + | "entity": "", + | "field": "", + | "query": "" + |} + |``` + | + |What each key does at runtime: + | + |* **`entity`** — the name of the Dynamic Entity to read from / write to. The current implementation only supports **one entity per mapping**; only the first `entity` value encountered is used. + |* **`field`** — the Dynamic Entity field whose value populates the `` in the output JSON. When the Dynamic Endpoint URL includes a query string (e.g. `?status=available`), `field` is also the Dynamic Entity field used to filter records. + |* **`query`** — the Dynamic Entity field used as the **lookup key** when the URL has a path parameter whose name contains "id" (e.g. `/pet/{petId}`). OBP uses the **first** `query` value in the mapping as this lookup key, so by convention all entries in a mapping repeat the same `query` value (it is per-mapping, not per-field, in practice). + | + |Worked example. Endpoint served URL `/obp/dynamic-endpoint/pet/{petId}` with mapping: + | + |``` + |{ + | "operation_id": "OBPv4.0.0-dynamicEndpoint_GET_pet_PET_ID", + | "request_mapping": {}, + | "response_mapping": { + | "id": { "entity": "PetEntity", "field": "field1", "query": "field1" }, + | "name": { "entity": "PetEntity", "field": "field4", "query": "field1" }, + | "status": { "entity": "PetEntity", "field": "field8", "query": "field1" } + | } + |} + |``` + | + |When called as `GET /pet/123`, OBP: + | + |1. Takes the first `query` value — `"field1"` — and the URL path value `123`. + |2. Finds the `PetEntity` record where `field1 == 123`. + |3. Builds the response body by copying that record's `field1` → `id`, `field4` → `name`, `field8` → `status`. + | + |Notes and caveats: + | + |* Non-id-named path parameters (anything that does not contain "id") are not used for lookup. + |* URL query-string filtering uses `field`, **not** `query`. A call like `GET /pets?status=available` filters `PetEntity` records by the `field` value on the mapping entry whose key matches `status` — in the example above, by `field8 == "available"`. + |* `request_mapping` is used on write operations (POST/PUT) to translate the inbound payload to a Dynamic Entity record; leave it as `{}` for read-only operations. | |For more details and a walk through, please see the following video: | diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala index d9e2337b43..255f0720d6 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -3949,6 +3949,10 @@ object NewStyle extends MdcLoggable{ Future(MappedApiProductsProvider.getApiProductsByBankId(bankId), callContext) } + def getApiProductsByBankId(bankId: String, tag: Option[String], callContext: Option[CallContext]): OBPReturnType[List[ApiProductTrait]] = { + Future(MappedApiProductsProvider.getApiProductsByBankId(bankId, tag), callContext) + } + def createOrUpdateApiProduct( bankId: String, apiProductCode: String, @@ -3967,6 +3971,7 @@ object NewStyle extends MdcLoggable{ perDayCallLimit: Long, perWeekCallLimit: Long, perMonthCallLimit: Long, + tags: List[String], callContext: Option[CallContext] ): OBPReturnType[ApiProductTrait] = { Future(MappedApiProductsProvider.createOrUpdateApiProduct( @@ -3974,7 +3979,8 @@ object NewStyle extends MdcLoggable{ moreInfoUrl, termsAndConditionsUrl, description, collectionId, monthlySubscriptionCurrency, monthlySubscriptionAmount, perSecondCallLimit, perMinuteCallLimit, perHourCallLimit, - perDayCallLimit, perWeekCallLimit, perMonthCallLimit + perDayCallLimit, perWeekCallLimit, perMonthCallLimit, + tags )) map { i => (unboxFullOrFail(i, callContext, CreateApiProductError), callContext) } diff --git a/obp-api/src/main/scala/code/api/util/http4s/AppsPage.scala b/obp-api/src/main/scala/code/api/util/http4s/AppsPage.scala new file mode 100644 index 0000000000..f115f233db --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/http4s/AppsPage.scala @@ -0,0 +1,145 @@ +package code.api.util.http4s + +import cats.effect.IO +import code.api.util.APIUtil +import org.http4s._ +import org.http4s.dsl.io._ +import org.http4s.headers.{Accept, `Content-Type`} +import org.http4s.Charset + +object AppsPage { + + private def appDiscoveryPairs = APIUtil.getAppDiscoveryPairs + + private val acronyms = Set("obp", "api", "mcp") + + // Render order for probe endpoints (also controls which endpoints are known). + private val probeEndpoints = List("status", "health", "ready") + + // For each app-key, which probe endpoints it exposes. Rendered as JSON fields + // (e.g. "status_url") and HTML links (e.g. [status]) in `probeEndpoints` order. + private val appProbes: Map[String, Set[String]] = Map( + "public_obp_portal_url" -> Set("status"), + "public_obp_api_manager_url" -> Set("status"), + "public_obp_oidc_url" -> Set("status"), + "public_obp_api_url" -> Set("status", "health"), + "public_obp_opey_url" -> Set("status", "health"), + "public_obp_api_explorer_url" -> Set("status", "health"), + "public_obp_mcp_url" -> Set("status", "health", "ready"), + ) + + private def probesFor(key: String): List[String] = + appProbes.get(key).map(s => probeEndpoints.filter(s.contains)).getOrElse(Nil) + + private def probeUrl(baseUrl: String, endpoint: String): String = + s"${baseUrl.stripSuffix("/")}/$endpoint" + + private def humanName(key: String): String = + key.stripPrefix("public_") + .stripSuffix("_url") + .replace("_", " ") + .split(" ") + .map(w => if (acronyms.contains(w.toLowerCase)) w.toUpperCase else w.capitalize) + .mkString(" ") + + private def prefersJson(req: Request[IO]): Boolean = + req.headers.get[Accept].exists { accept => + accept.values.toList.exists { mediaRange => + mediaRange.mediaRange == MediaType.application.json + } + } + + val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> Root => + if (prefersJson(req)) jsonResponse else htmlResponse + case req @ GET -> Root / "apps" => + if (prefersJson(req)) jsonResponse else htmlResponse + } + + private def jsonResponse: IO[Response[IO]] = { + val pairs = appDiscoveryPairs + val appDirectory = pairs.map { case (name, url) => + val probeFields = probesFor(name) + .map(ep => s""", "${ep}_url": "${probeUrl(url, ep)}"""") + .mkString + s""" {"name": "${humanName(name)}", "key": "$name", "url": "$url"$probeFields}""" + }.mkString(",\n") + + val json = + s"""{ + | "app_directory": [ + |$appDirectory + | ], + | "discovery_endpoints": { + | "api_info": "/obp/v6.0.0/root", + | "resource_docs": "/obp/v6.0.0/resource-docs/v6.0.0/obp", + | "well_known": "/obp/v5.1.0/well-known", + | "banks": "/obp/v6.0.0/banks" + | }, + | "links": { + | "github": "https://github.com/OpenBankProject/OBP-API", + | "tesobe": "https://www.tesobe.com", + | "open_bank_project": "https://www.openbankproject.com" + | }, + | "copyright": "Copyright TESOBE GmbH 2010-2026" + |}""".stripMargin + + Ok(json).map(_.withContentType(`Content-Type`(MediaType.application.json, Charset.`UTF-8`))) + } + + private def htmlResponse: IO[Response[IO]] = { + val appDiscoveryLinks = appDiscoveryPairs.map { case (name, url) => + val probeLinks = probesFor(name) + .map(ep => s""" [$ep]""") + .mkString + s"""
  • ${humanName(name)} ($name)$probeLinks
  • """ + }.mkString("\n") + + val html = + s""" + | + | + | OBP API - Apps + | + | + | + |

    Welcome to the OBP API technical discovery page

    + |

    OBP API is a headless open source Open Banking API stack. Navigate to the Apps below to interact with the APIs or see the Discovery Endpoints.

    + | + |

    App Directory

    + |
      + |$appDiscoveryLinks + |
    + | + |

    Discovery Endpoints

    + |

    See also API Explorer, Portal or MCP Server above.

    + | + | + |

    Links

    + | + | + |
    + | Copyright TESOBE GmbH 2010-2026 + |
    + | + |""".stripMargin + + Ok(html).map(_.withContentType(`Content-Type`(MediaType.text.html, Charset.`UTF-8`))) + } +} diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index 4b9cd39495..1dd8ef1135 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala @@ -55,6 +55,7 @@ object Http4sApp { */ private def baseServices: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => corsHandler.run(req) + .orElse(AppsPage.routes.run(req)) .orElse(StatusPage.routes.run(req)) .orElse(code.api.v5_0_0.Http4s500.wrappedRoutesV500Services.run(req)) .orElse(code.api.v7_0_0.Http4s700.wrappedRoutesV700Services.run(req)) diff --git a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala index 0f93764a9d..0f5c8037b7 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala @@ -1,24 +1,36 @@ package code.api.util.http4s +import java.lang.management.ManagementFactory + import cats.effect.IO -import code.api.util.APIUtil +import code.api.cache.Redis +import code.api.util.DoobieUtil +import code.util.Helper.MdcLoggable +import doobie._ +import doobie.implicits._ import org.http4s._ import org.http4s.dsl.io._ import org.http4s.headers.{Accept, `Content-Type`} +import org.http4s.Charset -object StatusPage { +object StatusPage extends MdcLoggable { - private def appDiscoveryPairs = APIUtil.getAppDiscoveryPairs + private lazy val gitProps: java.util.Properties = { + val props = new java.util.Properties() + val is = getClass.getResourceAsStream("/git.properties") + if (is != null) { + try props.load(is) finally is.close() + } + props + } - private val acronyms = Set("obp", "api", "mcp") + private def gitCommit: String = + Option(gitProps.getProperty("git.commit.id")).getOrElse("unknown") - private def humanName(key: String): String = - key.stripPrefix("public_") - .stripSuffix("_url") - .replace("_", " ") - .split(" ") - .map(w => if (acronyms.contains(w.toLowerCase)) w.toUpperCase else w.capitalize) - .mkString(" ") + private def apiInstanceId: String = code.api.Constant.ApiInstanceId + + private def uptimeSeconds: Long = + ManagementFactory.getRuntimeMXBean.getUptime / 1000L private def prefersJson(req: Request[IO]): Boolean = req.headers.get[Accept].exists { accept => @@ -27,89 +39,98 @@ object StatusPage { } } - val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> Root => - if (prefersJson(req)) jsonResponse else htmlResponse + private case class Checks(database: String, redis: String) { + def allOk: Boolean = database == "ok" && redis == "ok" } - private def jsonResponse: IO[Response[IO]] = { - val pairs = appDiscoveryPairs - val appDirectory = pairs.map { case (name, url) => - s""" {"name": "${humanName(name)}", "key": "$name", "url": "$url"}""" - }.mkString(",\n") + private def runChecks: IO[Checks] = IO { + val db = try { + DoobieUtil.runQuery(sql"SELECT 1".query[Int].unique) + "ok" + } catch { + case e: Throwable => + logger.warn(s"StatusPage says: database check failed: ${e.getMessage}") + "fail" + } + val redis = try { + if (Redis.isRedisReady) "ok" else "fail" + } catch { + case e: Throwable => + logger.warn(s"StatusPage says: redis check failed: ${e.getMessage}") + "fail" + } + Checks(db, redis) + } + val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> Root / "status" => + runChecks.flatMap { checks => + val response = if (prefersJson(req)) jsonResponse(checks) else htmlResponse(checks) + if (checks.allOk) response + else response.map(_.withStatus(Status.ServiceUnavailable)) + } + + // Liveness probe: the process is running and can respond to HTTP. + // Does not touch DB or Redis — those belong in /status (readiness). + case GET -> Root / "health" => + Ok("""{"status":"ok"}""").map(_.withContentType(`Content-Type`(MediaType.application.json, Charset.`UTF-8`))) + } + + private def jsonResponse(checks: Checks): IO[Response[IO]] = { + val status = if (checks.allOk) "ok" else "degraded" val json = s"""{ - | "app_directory": [ - |$appDirectory - | ], - | "discovery_endpoints": { - | "api_info": "/obp/v6.0.0/root", - | "resource_docs": "/obp/v6.0.0/resource-docs/v6.0.0/obp", - | "well_known": "/obp/v5.1.0/well-known", - | "banks": "/obp/v6.0.0/banks" - | }, - | "links": { - | "github": "https://github.com/OpenBankProject/OBP-API", - | "tesobe": "https://www.tesobe.com", - | "open_bank_project": "https://www.openbankproject.com" - | }, - | "copyright": "Copyright TESOBE GmbH 2010-2026" + | "status": "$status", + | "api_instance_id": "$apiInstanceId", + | "git_commit": "$gitCommit", + | "uptime_seconds": $uptimeSeconds, + | "checks": { + | "database": "${checks.database}", + | "redis": "${checks.redis}" + | } |}""".stripMargin - - Ok(json).map(_.withContentType(`Content-Type`(MediaType.application.json))) + Ok(json).map(_.withContentType(`Content-Type`(MediaType.application.json, Charset.`UTF-8`))) } - private def htmlResponse: IO[Response[IO]] = { - val appDiscoveryLinks = appDiscoveryPairs.map { case (name, url) => - s"""
  • ${humanName(name)} ($name)
  • """ - }.mkString("\n") + private def htmlResponse(checks: Checks): IO[Response[IO]] = { + val overall = if (checks.allOk) "ok" else "degraded" + val overallColor = if (checks.allOk) "#2e7d32" else "#c62828" + def badge(v: String): String = { + val color = if (v == "ok") "#2e7d32" else "#c62828" + s"""$v""" + } val html = s""" | | - | OBP API - Status Page + | OBP API - Status | | | - |

    Welcome to the OBP API technical discovery page

    - |

    OBP API is a headless open source Open Banking API stack. Navigate to the Apps below to interact with the APIs or see the Discovery Endpoints.

    - | - |

    App Directory

    - |
      - |$appDiscoveryLinks - |
    - | - |

    Discovery Endpoints

    - |

    See also API Explorer, Portal or MCP Server above.

    - | + |

    OBP API Status: $overall

    | - |

    Links

    - | + |

    Instance

    + | + | + | + | + |
    api_instance_id$apiInstanceId
    git_commit$gitCommit
    uptime_seconds$uptimeSeconds
    | - |
    - | Copyright TESOBE GmbH 2010-2026 - |
    + |

    Checks

    + | + | + | + |
    database${badge(checks.database)}
    redis${badge(checks.redis)}
    | |""".stripMargin - Ok(html).map(_.withContentType(`Content-Type`(MediaType.text.html))) + Ok(html).map(_.withContentType(`Content-Type`(MediaType.text.html, Charset.`UTF-8`))) } } 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 77494fe69f..557bd7b0fe 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 @@ -3,7 +3,9 @@ package code.api.v6_0_0 import scala.language.reflectiveCalls import code.accountattribute.AccountAttributeX import code.api.Constant._ -import code.api.{Constant, DirectLogin, ObpApiFailure} +import code.api.{Constant, DirectLogin, JsonResponseException, ObpApiFailure} +import code.api.dynamic.endpoint.helper.CompiledObjects +import code.dynamicResourceDoc.JsonDynamicResourceDoc import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.cache.{Caching, Redis, RedisMessaging} import code.api.util.APIUtil._ @@ -32,6 +34,7 @@ import code.api.v5_1_0.{JSONFactory510, PostCustomerLegalNameJsonV510} import code.api.dynamic.entity.helper.{DynamicEntityHelper, DynamicEntityInfo} import code.api.v6_0_0.JSONFactory600.{AddUserToGroupResponseJsonV600, CleanupOrphanedDynamicEntityResponseJsonV600, DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, OrphanedDynamicEntityJsonV600, GroupEntitlementJsonV600, GroupEntitlementsJsonV600, GroupJsonV600, GroupsJsonV600, ModeratedAccountJSON600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PostResetPasswordUrlAnonymousJsonV600, PostResetPasswordCompleteJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, ResetPasswordUrlAnonymousResponseJsonV600, ResetPasswordCompleteResponseJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ScannedApiVersionJsonV600, UpdateViewJsonV600, UserGroupMembershipJsonV600, UserGroupMembershipsJsonV600, UserWithViewAccessJsonV600, UsersWithViewAccessJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, ViewJsonV600, ViewPermissionJsonV600, ViewPermissionsJsonV600, ViewsJsonV600, createAbacRuleJsonV600, createAbacRulesJsonV600, createActiveRateLimitsJsonV600, createActiveRateLimitsJsonV600FromCallLimit, createBankAccountJSON600, createCallLimitJsonV600, createConsumerJsonV600, createRedisCallCountersJson, createFeaturedApiCollectionJsonV600, createFeaturedApiCollectionsJsonV600} import code.metadata.tags.Tags +import code.products.ProductTagsProvider import code.api.v6_0_0.OBPAPI6_0_0 import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} import code.mandate.{MappedMandateProvider} @@ -69,7 +72,7 @@ import org.apache.commons.lang3.StringUtils import net.liftweb.http.provider.HTTPParam import net.liftweb.http.rest.RestHelper import net.liftweb.json.{Extraction, JsonParser} -import net.liftweb.json.JsonAST.{JArray, JField, JObject, JString, JValue} +import net.liftweb.json.JsonAST.{JArray, JField, JNothing, JObject, JString, JValue} import net.liftweb.json.JsonDSL._ import net.liftweb.mapper.{By, Descending, MaxRows, NullRef, OrderBy} import code.api.util.ExampleValue @@ -7897,6 +7900,111 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + validateDynamicResourceDoc, + implementedInApiVersion, + nameOf(validateDynamicResourceDoc), + "POST", + "/management/dynamic-resource-docs/validate", + "Validate Dynamic Resource Doc", + s"""Dry-run validation of a Dynamic Resource Doc. Send the same payload you would send to `Create Dynamic Resource Doc` and this endpoint will: + | + |- Parse `method_body` (URL-decoded) as Scala code and run the ToolBox compiler against it, wrapped in the same template used at runtime (request/response case classes generated from `example_request_body` / `success_response_body`). + |- Run the OBP compilation-dependency guard (when the OBP prop `dynamic_code_compile_validate_enable` is set to `true`). + | + |Always returns HTTP 200. Inspect the `valid` field in the response: + | + |* `true` — the Scala compiles and all referenced OBP methods are on the allowlist. + |* `false` — the response includes `error` (raw compiler / guard message), `message` (OBP error constant) and `details.error_type` — one of: + | * `CompilationError` — `method_body` failed to compile. + | * `DependencyError` — compiled, but references OBP types/methods that the admin has not allowed in `dynamic_code_compile_validate_dependencies`. + | * `UnknownError` — any other unexpected exception. + | + |Nothing is persisted and no endpoint is served as a result of calling this. + | + |${userAuthenticationMessage(true)} + |""".stripMargin, + jsonDynamicResourceDoc.copy(dynamicResourceDocId = None), + ValidateDynamicResourceDocSuccessJsonV600( + valid = true, + message = "Dynamic Resource Doc method body is valid Scala and uses allowed dependencies." + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagDynamicResourceDoc), + Some(List(canCreateDynamicResourceDoc)) + ) + + lazy val validateDynamicResourceDoc: OBPEndpoint = { + case "management" :: "dynamic-resource-docs" :: "validate" :: Nil JsonPost json -> _ => { + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (Full(user), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", user.userId, canCreateDynamicResourceDoc, callContext) + body <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the $JsonDynamicResourceDoc", + 400, + callContext + ) { + json.extract[JsonDynamicResourceDoc] + } + _ <- Helper.booleanToFuture( + failMsg = s"""$InvalidJsonFormat The request_verb must be one of ["POST", "PUT", "GET", "DELETE"]""", + cc = callContext + ) { + Set("POST", "PUT", "GET", "DELETE").contains(body.requestVerb) + } + _ <- Helper.booleanToFuture( + failMsg = s"""$InvalidJsonFormat When request_verb is "GET" or "DELETE", the example_request_body must be a blank String "" or just totally omit the field""", + cc = callContext + ) { + (body.requestVerb, body.exampleRequestBody) match { + case ("GET" | "DELETE", Some(JString(s))) => StringUtils.isBlank(s) + case ("GET" | "DELETE", Some(rb)) => rb == JNothing + case _ => true + } + } + result = try { + CompiledObjects(body.exampleRequestBody, body.successResponseBody, body.methodBody) + .validateDependency() + ValidateDynamicResourceDocSuccessJsonV600( + valid = true, + message = "Dynamic Resource Doc method body is valid Scala and uses allowed dependencies." + ) + } catch { + case e: JsonResponseException => + // validateDependency throws JsonResponseException (OBP-40046) when the compiled + // code references types/methods outside the compile-time allowlist. The useful + // error text is in the response body, not getMessage. + val errorText = e.jsonResponse match { + case JsonResponseExtractor(msg, _) => msg + case _ => "" + } + ValidateDynamicResourceDocFailureJsonV600( + valid = false, + error = errorText, + message = DynamicResourceDocMethodDependency, + details = ValidateDynamicResourceDocErrorDetailsJsonV600(error_type = "DependencyError") + ) + case e: Exception => + ValidateDynamicResourceDocFailureJsonV600( + valid = false, + error = Option(e.getMessage).getOrElse(""), + message = DynamicCodeCompileFail, + details = ValidateDynamicResourceDocErrorDetailsJsonV600(error_type = "CompilationError") + ) + } + } yield { + (result, HttpCode.`200`(callContext)) + } + } + } + staticResourceDocs += ResourceDoc( executeAbacRule, implementedInApiVersion, @@ -9418,6 +9526,7 @@ trait APIMethods600 { postJson.per_day_call_limit.getOrElse(-1L), postJson.per_week_call_limit.getOrElse(-1L), postJson.per_month_call_limit.getOrElse(-1L), + postJson.tags.getOrElse(Nil), callContext ) } yield { @@ -9479,6 +9588,7 @@ trait APIMethods600 { postJson.per_day_call_limit.getOrElse(-1L), postJson.per_week_call_limit.getOrElse(-1L), postJson.per_month_call_limit.getOrElse(-1L), + postJson.tags.getOrElse(Nil), callContext ) } yield { @@ -9541,6 +9651,8 @@ trait APIMethods600 { "/banks/BANK_ID/api-products", "Get Api Products", s"""Get Api Products for the Bank. + | + |Optional query parameter: `tag` — filter to products that have the given tag (e.g. `?tag=featured`). Tag matching is case-insensitive. | |${userAuthenticationMessage(!getApiProductsIsPublic)} | @@ -9553,7 +9665,7 @@ trait APIMethods600 { ) lazy val getApiProducts: OBPEndpoint = { - case "banks" :: BankId(bankId) :: "api-products" :: Nil JsonGet _ => { + case "banks" :: BankId(bankId) :: "api-products" :: Nil JsonGet req => { cc => implicit val ec = EndpointContext(Some(cc)) for { (_, callContext) <- getApiProductsIsPublic match { @@ -9569,13 +9681,69 @@ trait APIMethods600 { Future.successful(callContext) } _ <- NewStyle.function.getBank(bankId, callContext) - (apiProducts, callContext) <- NewStyle.function.getApiProductsByBankId(bankId.value, callContext) + tagFilter = req.params.get("tag").flatMap(_.headOption).map(_.trim).filter(_.nonEmpty) + (apiProducts, callContext) <- NewStyle.function.getApiProductsByBankId(bankId.value, tagFilter, callContext) } yield { (JSONFactory600.createApiProductsJsonV600(apiProducts), HttpCode.`200`(callContext)) } } } + staticResourceDocs += ResourceDoc( + getAllApiProductsV600, + implementedInApiVersion, + nameOf(getAllApiProductsV600), + "GET", + "/api-products", + "Get Api Products At All Banks", + s"""Returns the Api Products across every bank, merged into a single list. Each product carries its `bank_id`. + | + |Optional query parameter `tag` — filter to products that have the given tag (e.g. `?tag=featured`). Tag matching is case-insensitive. + | + |${userAuthenticationMessage(!getApiProductsIsPublic)}""".stripMargin, + EmptyBody, + apiProductsJsonV600, + List(UnknownError), + List(apiTagApi, apiTagApiProduct) + ) + + lazy val getAllApiProductsV600: OBPEndpoint = { + case "api-products" :: Nil JsonGet req => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, callContext) <- getApiProductsIsPublic match { + case false => authenticatedAccess(cc) + case true => anonymousAccess(cc) + } + tagFilter = req.params.get("tag").flatMap(_.headOption).map(_.trim).filter(_.nonEmpty) + resultJson <- { + implicit val formats: net.liftweb.json.Formats = net.liftweb.json.DefaultFormats + val cacheKey = s"all:${tagFilter.getOrElse("")}" + val cacheTTL = APIUtil.getPropsAsIntValue("getAllApiProductsV600.cache.ttl.seconds", 5) + val hit = Caching.getApiProductsCache(cacheKey, cacheTTL) + .flatMap(s => try Some(net.liftweb.json.parse(s).extract[ApiProductsJsonV600]) catch { case _: Throwable => None }) + hit match { + case Some(cached) => Future.successful(cached) + case None => + for { + (banks, _) <- NewStyle.function.getBanks(callContext).map(t => (t._1, t._2)) + perBank <- Future.sequence( + banks.map(b => NewStyle.function.getApiProductsByBankId(b.bankId.value, tagFilter, callContext).map(_._1)) + ) + apiProducts = perBank.flatten + } yield { + val result = JSONFactory600.createApiProductsJsonV600(apiProducts) + Caching.setApiProductsCache(cacheKey, net.liftweb.json.compactRender(Extraction.decompose(result)), cacheTTL) + result + } + } + } + } yield { + (resultJson, HttpCode.`200`(callContext)) + } + } + } + staticResourceDocs += ResourceDoc( deleteApiProduct, implementedInApiVersion, @@ -9616,6 +9784,214 @@ trait APIMethods600 { } } + // Financial Product Endpoints (v6.0.0 — adds tag support) + + staticResourceDocs += ResourceDoc( + getProductsV600, + implementedInApiVersion, + nameOf(getProductsV600), + "GET", + "/banks/BANK_ID/products", + "Get Products", + s"""Returns the financial Products offered by the bank specified by BANK_ID. Response includes the new `tags` field. + | + |Optional query parameter `tag` — filter to products that carry the given tag (case-insensitive). Repeat `tag=` to require multiple tags (e.g. `?tag=featured&tag=new`). + | + |${userAuthenticationMessage(!getProductsIsPublic)}""".stripMargin, + EmptyBody, + productsJsonV600, + List( + BankNotFound, + UnknownError + ), + List(apiTagProduct) + ) + + lazy val getProductsV600: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "products" :: Nil JsonGet req => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, callContext) <- getProductsIsPublic match { + case false => authenticatedAccess(cc) + case true => anonymousAccess(cc) + } + (_, callContext) <- NewStyle.function.getBank(bankId, callContext) + params = req.params.toList.map(kv => GetProductsParam(kv._1, kv._2)) + resultJson <- { + // Short TTL is the freshness guarantee; an admin tag change becomes visible within the TTL. + // Redis-backed with versioned namespace prefix so the cache shows up on /system/cache/info + // and can be invalidated by bumping the namespace version. + implicit val formats: net.liftweb.json.Formats = net.liftweb.json.DefaultFormats + val cacheKey = APIMethods600.productsCacheKey(bankId.value, params) + val cacheTTL = APIUtil.getPropsAsIntValue("getProductsV600.cache.ttl.seconds", 5) + val hit = Caching.getFinancialProductsCache(cacheKey, cacheTTL) + .flatMap(s => try Some(net.liftweb.json.parse(s).extract[ProductsJsonV600]) catch { case _: Throwable => None }) + hit match { + case Some(cached) => Future.successful(cached) + case None => + for { + (products, _) <- NewStyle.function.getProducts(bankId, params, callContext) + } yield { + val tagsByCode = ProductTagsProvider.getTagsByProductCodes(bankId, products.map(_.code.value)) + val result = JSONFactory600.createProductsJsonV600(products, tagsByCode) + Caching.setFinancialProductsCache(cacheKey, net.liftweb.json.compactRender(Extraction.decompose(result)), cacheTTL) + result + } + } + } + } yield { + (resultJson, HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getAllProductsV600, + implementedInApiVersion, + nameOf(getAllProductsV600), + "GET", + "/products", + "Get Products At All Banks", + s"""Returns the financial Products offered by every bank this instance knows about, merged into a single list. Each product carries its `bank_id`. + | + |Optional query parameter `tag` — filter to products that carry the given tag (e.g. `?tag=featured`). Tag matching is case-insensitive. Repeat `tag=` to require multiple tags. + | + |${userAuthenticationMessage(!getProductsIsPublic)}""".stripMargin, + EmptyBody, + productsJsonV600, + List(UnknownError), + List(apiTagProduct) + ) + + lazy val getAllProductsV600: OBPEndpoint = { + case "products" :: Nil JsonGet req => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, callContext) <- getProductsIsPublic match { + case false => authenticatedAccess(cc) + case true => anonymousAccess(cc) + } + params = req.params.toList.map(kv => GetProductsParam(kv._1, kv._2)) + resultJson <- { + implicit val formats: net.liftweb.json.Formats = net.liftweb.json.DefaultFormats + val cacheKey = APIMethods600.productsCacheKey("__all__", params) + val cacheTTL = APIUtil.getPropsAsIntValue("getAllProductsV600.cache.ttl.seconds", 60) + val hit = Caching.getFinancialProductsCache(cacheKey, cacheTTL) + .flatMap(s => try Some(net.liftweb.json.parse(s).extract[ProductsJsonV600]) catch { case _: Throwable => None }) + hit match { + case Some(cached) => Future.successful(cached) + case None => + for { + (banks, _) <- NewStyle.function.getBanks(callContext).map(t => (t._1, t._2)) + // Fan-out server-side: one getProducts call per bank. The whole fan-out is cached + // so the per-bank cost is paid once per TTL. + perBank <- Future.sequence( + banks.map(b => NewStyle.function.getProducts(b.bankId, params, callContext).map(_._1)) + ) + products = perBank.flatten + } yield { + val tagsByBank = banks.map { b => + val codesForBank = products.filter(_.bankId == b.bankId).map(_.code.value) + b.bankId.value -> ProductTagsProvider.getTagsByProductCodes(b.bankId, codesForBank) + }.toMap + val tagsByCode = tagsByBank.values.foldLeft(Map.empty[String, List[String]])(_ ++ _) + val result = JSONFactory600.createProductsJsonV600(products, tagsByCode) + Caching.setFinancialProductsCache(cacheKey, net.liftweb.json.compactRender(Extraction.decompose(result)), cacheTTL) + result + } + } + } + } yield { + (resultJson, HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getProductTagsV600, + implementedInApiVersion, + nameOf(getProductTagsV600), + "GET", + "/banks/BANK_ID/products/PRODUCT_CODE/tags", + "Get Product Tags", + s"""Returns the list of tags currently set on the financial Product. + | + |${userAuthenticationMessage(!getProductsIsPublic)}""".stripMargin, + EmptyBody, + productTagsJsonV600, + List( + BankNotFound, + ProductNotFoundByProductCode, + UnknownError + ), + List(apiTagProduct) + ) + + lazy val getProductTagsV600: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "products" :: ProductCode(productCode) :: "tags" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, callContext) <- getProductsIsPublic match { + case false => authenticatedAccess(cc) + case true => anonymousAccess(cc) + } + (_, callContext) <- NewStyle.function.getBank(bankId, callContext) + (_, callContext) <- NewStyle.function.getProduct(bankId, productCode, callContext) + tags = ProductTagsProvider.getTags(bankId, productCode) + } yield { + (JSONFactory600.createProductTagsJsonV600(tags), HttpCode.`200`(callContext)) + } + } + } + + val updateProductTagsEntitlements = canUpdateProductTagsAtOneBank :: canUpdateProductTagsAtAnyBank :: Nil + val updateProductTagsEntitlementsRequiredText = UserHasMissingRoles + updateProductTagsEntitlements.mkString(" or ") + + staticResourceDocs += ResourceDoc( + updateProductTagsV600, + implementedInApiVersion, + nameOf(updateProductTagsV600), + "PUT", + "/banks/BANK_ID/products/PRODUCT_CODE/tags", + "Update Product Tags", + s"""Replaces the tags on a financial Product. Tags are free-form string labels (e.g. `featured`, `new`, `beta`). Tag matching in queries is case-insensitive. + | + |Authentication is Required.""".stripMargin, + productTagsJsonV600, + productTagsJsonV600, + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + InvalidJsonFormat, + BankNotFound, + ProductNotFoundByProductCode, + UnknownError + ), + List(apiTagProduct), + Some(updateProductTagsEntitlements) + ) + + lazy val updateProductTagsV600: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "products" :: ProductCode(productCode) :: "tags" :: Nil JsonPut json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasAtLeastOneEntitlement(failMsg = updateProductTagsEntitlementsRequiredText)(bankId.value, u.userId, updateProductTagsEntitlements, callContext) + (_, callContext) <- NewStyle.function.getBank(bankId, callContext) + (_, callContext) <- NewStyle.function.getProduct(bankId, productCode, callContext) + body <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $ProductTagsJsonV600", 400, callContext) { + json.extract[ProductTagsJsonV600] + } + updatedTags <- NewStyle.function.tryons(s"$UpdateProductError", 400, callContext) { + ProductTagsProvider.setTags(bankId, productCode, body.tags) + .openOrThrowException(UpdateProductError) + } + } yield { + (JSONFactory600.createProductTagsJsonV600(updatedTags), HttpCode.`200`(callContext)) + } + } + } + // Api Product Attribute Endpoints staticResourceDocs += ResourceDoc( @@ -16759,4 +17135,15 @@ object APIMethods600 extends RestHelper with APIMethods600 { lazy val newStyleEndpoints: List[(String, String)] = Implementations6_0_0.resourceDocs.map { rd => (rd.partialFunctionName, rd.implementedInApiVersion.toString()) }.toList + + // Canonical cache key for product-list endpoints. Params are sorted by name (and by value within each) + // so that `?tag=a&tag=b` and `?tag=b&tag=a` share a cache entry. Bank is "__all__" for the system-level endpoint. + def productsCacheKey(bankId: String, params: List[GetProductsParam]): String = { + val canonical = params + .map(p => p.name -> p.value.sorted) + .sortBy(_._1) + .map { case (name, values) => s"$name=${values.mkString(",")}" } + .mkString("&") + s"productsV600:$bankId:$canonical" + } } 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 db89ee15d3..132f487163 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 @@ -840,6 +840,22 @@ case class ValidateAbacRuleFailureJsonV600( details: ValidateAbacRuleErrorDetailsJsonV600 ) +case class ValidateDynamicResourceDocSuccessJsonV600( + valid: Boolean, + message: String +) + +case class ValidateDynamicResourceDocErrorDetailsJsonV600( + error_type: String +) + +case class ValidateDynamicResourceDocFailureJsonV600( + valid: Boolean, + error: String, + message: String, + details: ValidateDynamicResourceDocErrorDetailsJsonV600 +) + case class AbacParameterJsonV600( name: String, `type`: String, @@ -1035,7 +1051,8 @@ case class PostPutApiProductJsonV600( per_hour_call_limit: Option[Long], per_day_call_limit: Option[Long], per_week_call_limit: Option[Long], - per_month_call_limit: Option[Long] + per_month_call_limit: Option[Long], + tags: Option[List[String]] ) case class ApiProductJsonV600( @@ -1057,11 +1074,31 @@ case class ApiProductJsonV600( per_day_call_limit: Long, per_week_call_limit: Long, per_month_call_limit: Long, + tags: List[String], attributes: Option[List[ApiProductAttributeResponseJsonV600]] ) case class ApiProductsJsonV600(api_products: List[ApiProductJsonV600]) +// Financial Product (v6.0.0) — adds `tags` on top of the v4.0.0 shape. +case class ProductJsonV600( + bank_id: String, + product_code: String, + parent_product_code: String, + name: String, + more_info_url: String, + terms_and_conditions_url: String, + description: String, + meta: MetaJsonV140, + tags: List[String], + attributes: Option[List[ProductAttributeResponseWithoutBankIdJson]], + fees: Option[List[ProductFeeJsonV400]] +) + +case class ProductsJsonV600(products: List[ProductJsonV600]) + +case class ProductTagsJsonV600(tags: List[String]) + case class ApiProductAttributeJsonV600( name: String, `type`: String, @@ -2277,7 +2314,9 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { Constant.CONNECTOR_NAMESPACE -> ("Connector cache", "Connector"), Constant.METRICS_STABLE_NAMESPACE -> ("Stable metrics data", "Metrics"), Constant.METRICS_RECENT_NAMESPACE -> ("Recent metrics data", "Metrics"), - Constant.ABAC_RULE_NAMESPACE -> ("ABAC rule cache", "Authorization") + Constant.ABAC_RULE_NAMESPACE -> ("ABAC rule cache", "Authorization"), + Constant.FINANCIAL_PRODUCTS_NAMESPACE -> ("Financial product list (bank-scoped and all-banks)", "Products"), + Constant.API_PRODUCTS_NAMESPACE -> ("Api product list (all banks)", "Products") ) var redisAvailable = true @@ -2739,6 +2778,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { per_day_call_limit = product.perDayCallLimit, per_week_call_limit = product.perWeekCallLimit, per_month_call_limit = product.perMonthCallLimit, + tags = product.tags, attributes = attributes.map(_.map(createApiProductAttributeResponseJsonV600)) ) } @@ -2749,6 +2789,30 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ApiProductsJsonV600(products.map(p => createApiProductJsonV600(p, None))) } + def createProductJsonV600(product: Product, tags: List[String]): ProductJsonV600 = { + ProductJsonV600( + bank_id = product.bankId.value, + product_code = product.code.value, + parent_product_code = product.parentProductCode.value, + name = product.name, + more_info_url = product.moreInfoUrl, + terms_and_conditions_url = product.termsAndConditionsUrl, + description = product.description, + meta = createMetaJson(product.meta), + tags = tags, + attributes = None, + fees = None + ) + } + + def createProductsJsonV600(products: List[Product], tagsByCode: Map[String, List[String]]): ProductsJsonV600 = { + ProductsJsonV600(products.map(p => createProductJsonV600(p, tagsByCode.getOrElse(p.code.value, Nil)))) + } + + def createProductTagsJsonV600(tags: List[String]): ProductTagsJsonV600 = { + ProductTagsJsonV600(tags = tags) + } + def createConnectorTraceJsonV600(trace: ConnectorTrace): ConnectorTraceJsonV600 = { ConnectorTraceJsonV600( connector_trace_id = trace.id.get, diff --git a/obp-api/src/main/scala/code/apiproduct/ApiProduct.scala b/obp-api/src/main/scala/code/apiproduct/ApiProduct.scala index 73ce363a3d..d907a09b59 100644 --- a/obp-api/src/main/scala/code/apiproduct/ApiProduct.scala +++ b/obp-api/src/main/scala/code/apiproduct/ApiProduct.scala @@ -24,6 +24,8 @@ class ApiProduct extends ApiProductTrait with LongKeyedMapper[ApiProduct] with I object PerDayCallLimit extends MappedLong(this) { override def defaultValue = -1L } object PerWeekCallLimit extends MappedLong(this) { override def defaultValue = -1L } object PerMonthCallLimit extends MappedLong(this) { override def defaultValue = -1L } + // Pipe-delimited list of tags, e.g. "|featured|beta|". Leading/trailing pipes make LIKE filtering exact. + object Tags extends MappedString(this, 2000) override def apiProductId: String = ApiProductId.get override def bankId: String = BankId.get @@ -43,10 +45,26 @@ class ApiProduct extends ApiProductTrait with LongKeyedMapper[ApiProduct] with I override def perDayCallLimit: Long = PerDayCallLimit.get override def perWeekCallLimit: Long = PerWeekCallLimit.get override def perMonthCallLimit: Long = PerMonthCallLimit.get + override def tags: List[String] = ApiProduct.decodeTags(Tags.get) } object ApiProduct extends ApiProduct with LongKeyedMetaMapper[ApiProduct] { override def dbIndexes = UniqueIndex(BankId, ApiProductCode) :: Index(BankId) :: super.dbIndexes + + // Wire format: List[String]. Storage format: "|tag1|tag2|" (leading/trailing pipes so LIKE '%|foo|%' matches exactly). + // Tags are normalised: trimmed, lower-cased, pipe-stripped, de-duplicated, empty entries dropped. + def encodeTags(tags: List[String]): String = { + val normalised = tags + .map(_.trim.toLowerCase.replace("|", "")) + .filter(_.nonEmpty) + .distinct + if (normalised.isEmpty) "" else normalised.mkString("|", "|", "|") + } + + def decodeTags(stored: String): List[String] = { + if (stored == null || stored.isEmpty) Nil + else stored.split('|').toList.filter(_.nonEmpty) + } } trait ApiProductTrait { @@ -68,4 +86,5 @@ trait ApiProductTrait { def perDayCallLimit: Long def perWeekCallLimit: Long def perMonthCallLimit: Long + def tags: List[String] } diff --git a/obp-api/src/main/scala/code/apiproduct/ApiProductsProvider.scala b/obp-api/src/main/scala/code/apiproduct/ApiProductsProvider.scala index 61106f4da1..f6f7c6ef33 100644 --- a/obp-api/src/main/scala/code/apiproduct/ApiProductsProvider.scala +++ b/obp-api/src/main/scala/code/apiproduct/ApiProductsProvider.scala @@ -2,7 +2,7 @@ package code.apiproduct import code.util.Helper.MdcLoggable import net.liftweb.common.Box -import net.liftweb.mapper.By +import net.liftweb.mapper.{By, Like} import net.liftweb.util.Helpers.tryo trait ApiProductsProvider { @@ -23,7 +23,8 @@ trait ApiProductsProvider { perHourCallLimit: Long, perDayCallLimit: Long, perWeekCallLimit: Long, - perMonthCallLimit: Long + perMonthCallLimit: Long, + tags: List[String] ): Box[ApiProductTrait] def getApiProductByBankIdAndCode( @@ -32,7 +33,8 @@ trait ApiProductsProvider { ): Box[ApiProductTrait] def getApiProductsByBankId( - bankId: String + bankId: String, + tag: Option[String] = None ): List[ApiProductTrait] def deleteApiProduct( @@ -60,12 +62,14 @@ object MappedApiProductsProvider extends MdcLoggable with ApiProductsProvider { perHourCallLimit: Long, perDayCallLimit: Long, perWeekCallLimit: Long, - perMonthCallLimit: Long + perMonthCallLimit: Long, + tags: List[String] ): Box[ApiProductTrait] = { val existing = ApiProduct.find( By(ApiProduct.BankId, bankId), By(ApiProduct.ApiProductCode, apiProductCode) ) + val encodedTags = ApiProduct.encodeTags(tags) existing match { case net.liftweb.common.Full(product) => tryo( @@ -85,6 +89,7 @@ object MappedApiProductsProvider extends MdcLoggable with ApiProductsProvider { .PerDayCallLimit(perDayCallLimit) .PerWeekCallLimit(perWeekCallLimit) .PerMonthCallLimit(perMonthCallLimit) + .Tags(encodedTags) .saveMe() ) case _ => @@ -108,6 +113,7 @@ object MappedApiProductsProvider extends MdcLoggable with ApiProductsProvider { .PerDayCallLimit(perDayCallLimit) .PerWeekCallLimit(perWeekCallLimit) .PerMonthCallLimit(perMonthCallLimit) + .Tags(encodedTags) .saveMe() ) } @@ -122,8 +128,16 @@ object MappedApiProductsProvider extends MdcLoggable with ApiProductsProvider { ) override def getApiProductsByBankId( - bankId: String - ): List[ApiProductTrait] = ApiProduct.findAll(By(ApiProduct.BankId, bankId)) + bankId: String, + tag: Option[String] = None + ): List[ApiProductTrait] = { + val baseParams = List(By(ApiProduct.BankId, bankId)) + val params = tag.map(_.trim.toLowerCase).filter(_.nonEmpty) match { + case Some(t) => baseParams :+ Like(ApiProduct.Tags, s"%|$t|%") + case None => baseParams + } + ApiProduct.findAll(params: _*) + } override def deleteApiProduct( bankId: String, diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index cab71474e1..6cddd887ae 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -2560,13 +2560,31 @@ object LocalMappedConnector extends Connector with MdcLoggable { override def getProducts(bankId: BankId, params: List[GetProductsParam], callContext: Option[CallContext]): OBPReturnType[Box[List[Product]]] = { Future{Box !! { - if (params.isEmpty) { - MappedProduct.findAll(By(MappedProduct.mBankId, bankId.value)) + // `tag` params are resolved via the ProductTag table (AND semantics when repeated); the rest + // continue to be treated as MappedProductAttribute filters. + val (tagParams, attributeParams) = params.partition(_.name.toLowerCase == "tag") + val requestedTags = tagParams.flatMap(_.value).map(_.trim).filter(_.nonEmpty) + val codesFromTags: Option[Set[String]] = + if (requestedTags.isEmpty) None + else Some(code.products.ProductTagsProvider.getProductCodesWithAllTags(bankId, requestedTags)) + + // Short-circuit if the tag filter yielded no matches. + if (codesFromTags.exists(_.isEmpty)) Nil + else if (attributeParams.isEmpty) { + codesFromTags match { + case Some(codes) => + MappedProduct.findAll( + By(MappedProduct.mBankId, bankId.value), + ByList(MappedProduct.mCode, codes.toList) + ) + case None => + MappedProduct.findAll(By(MappedProduct.mBankId, bankId.value)) + } } else { - val paramList: List[(String, List[String])] = params.map(it => it.name -> it.value) + val paramList: List[(String, List[String])] = attributeParams.map(it => it.name -> it.value) val parameters: List[String] = MappedProductAttribute.getParameters(paramList) val sqlParametersFilter = MappedProductAttribute.getSqlParametersFilter(paramList) - val productIdList = paramList.isEmpty match { + val codesFromAttrs: List[String] = paramList.isEmpty match { case true => MappedProductAttribute.findAll( By(MappedProductAttribute.mBankId, bankId.value) @@ -2577,7 +2595,11 @@ object LocalMappedConnector extends Connector with MdcLoggable { BySql(sqlParametersFilter, IHaveValidatedThisSQL("developer","2020-06-28"), parameters:_*) ).map(_.productCode.value) } - MappedProduct.findAll(ByList(MappedProduct.mCode, productIdList)) + val finalCodes = codesFromTags match { + case Some(tagSet) => codesFromAttrs.filter(tagSet.contains) + case None => codesFromAttrs + } + MappedProduct.findAll(ByList(MappedProduct.mCode, finalCodes)) } } }}.map(products => (products, callContext)) @@ -3070,7 +3092,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { details: String, description: String, metaLicenceId: String, - metaLicenceName: String, + metaLicenceName: String, callContext: Option[CallContext]): OBPReturnType[Box[Product]] = Future{ //check the product existence and update or insert data diff --git a/obp-api/src/main/scala/code/metrics/ConnectorMetricBatchWriter.scala b/obp-api/src/main/scala/code/metrics/ConnectorMetricBatchWriter.scala new file mode 100644 index 0000000000..58a6de3d27 --- /dev/null +++ b/obp-api/src/main/scala/code/metrics/ConnectorMetricBatchWriter.scala @@ -0,0 +1,118 @@ +package code.metrics + +import java.sql.Timestamp +import java.util.Date +import java.util.concurrent.{ConcurrentLinkedQueue, Executors, TimeUnit} +import java.util.concurrent.atomic.AtomicBoolean + +import code.api.util.{APIUtil, DoobieUtil} +import code.util.Helper.MdcLoggable +import doobie._ +import doobie.implicits._ +import doobie.implicits.javasql._ + +/** + * Batched connector-metric writer. Mirrors MetricBatchWriter: metrics are enqueued + * in memory and flushed to the database periodically via a multi-value INSERT. + * + * Configuration: + * - connector.metrics.batch.interval.seconds: flush interval (default: 5) + */ +object ConnectorMetricBatchWriter extends MdcLoggable { + + case class ConnectorMetricRow( + connectorName: String, + functionName: String, + correlationId: String, + date: Date, + duration: Long, + requestParams: String, + isSuccessful: Boolean, + apiInstanceId: String + ) + + private val queue = new ConcurrentLinkedQueue[ConnectorMetricRow]() + + private val flushIntervalSeconds = + APIUtil.getPropsAsLongValue("connector.metrics.batch.interval.seconds", 5L) + + private val started = new AtomicBoolean(false) + + def start(): Unit = { + if (started.compareAndSet(false, true)) { + val scheduler = Executors.newSingleThreadScheduledExecutor { r => + val t = new Thread(r, "connector-metric-batch-writer") + t.setDaemon(true) + t + } + scheduler.scheduleWithFixedDelay( + () => flush(), + flushIntervalSeconds, + flushIntervalSeconds, + TimeUnit.SECONDS + ) + logger.info(s"ConnectorMetricBatchWriter says: started (flushInterval=${flushIntervalSeconds}s)") + } + } + + def enqueue(row: ConnectorMetricRow): Unit = { + queue.add(row) + } + + private[code] def flush(): Unit = { + try { + val batch = new java.util.ArrayList[ConnectorMetricRow]() + var item = queue.poll() + while (item != null) { + batch.add(item) + item = queue.poll() + } + + if (!batch.isEmpty) { + val rows = { + val buf = scala.collection.mutable.ListBuffer.empty[ConnectorMetricRow] + val it = batch.iterator() + while (it.hasNext) buf += it.next() + buf.toList + } + + val insertSql = """ + INSERT INTO mappedconnectormetric ( + connectorname, functionname, correlationid, date_c, + duration, requestparams, issuccessful, apiinstanceid + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """ + + val insert = Update[ + (Option[String], Option[String], Option[String], Timestamp, + Long, Option[String], Boolean, Option[String]) + ](insertSql) + + val values = rows.map { r => + ( + Option(r.connectorName), + Option(r.functionName), + Option(r.correlationId), + new Timestamp(if (r.date != null) r.date.getTime else 0L), + r.duration, + Option(r.requestParams), + r.isSuccessful, + Option(r.apiInstanceId) + ) + } + + // Explicit commit: background thread has no Lift request context, so + // DoobieUtil uses the shared HikariCP pool with Strategy.void. + val program: ConnectionIO[Int] = for { + n <- insert.updateMany(values) + _ <- FC.commit + } yield n + val count = DoobieUtil.runQuery(program) + logger.debug(s"ConnectorMetricBatchWriter says: flushed $count connector metrics via doobie-pool") + } + } catch { + case e: Exception => + logger.error(s"ConnectorMetricBatchWriter says: flush failed", e) + } + } +} diff --git a/obp-api/src/main/scala/code/metrics/ConnectorMetrics.scala b/obp-api/src/main/scala/code/metrics/ConnectorMetrics.scala index 94455a67ee..97087cdc02 100644 --- a/obp-api/src/main/scala/code/metrics/ConnectorMetrics.scala +++ b/obp-api/src/main/scala/code/metrics/ConnectorMetrics.scala @@ -15,16 +15,19 @@ object ConnectorMetrics extends ConnectorMetricsProvider { val cachedAllConnectorMetrics = APIUtil.getPropsValue(s"ConnectorMetrics.cache.ttl.seconds.getAllConnectorMetrics", "7").toInt override def saveConnectorMetric(connectorName: String, functionName: String, correlationId: String, date: Date, duration: Long, - requestParams: String, isSuccessful: Boolean): Unit = { - MappedConnectorMetric.create - .connectorName(connectorName) - .functionName(functionName) - .date(date) - .duration(duration) - .correlationId(correlationId) - .requestParams(requestParams) - .isSuccessful(isSuccessful) - .save + requestParams: String, isSuccessful: Boolean, apiInstanceId: String): Unit = { + ConnectorMetricBatchWriter.enqueue( + ConnectorMetricBatchWriter.ConnectorMetricRow( + connectorName = connectorName, + functionName = functionName, + correlationId = correlationId, + date = date, + duration = duration, + requestParams = requestParams, + isSuccessful = isSuccessful, + apiInstanceId = apiInstanceId + ) + ) } override def getAllConnectorMetrics(queryParams: List[OBPQueryParam]): List[MappedConnectorMetric] = { @@ -76,6 +79,7 @@ class MappedConnectorMetric extends ConnectorMetric with LongKeyedMapper[MappedC object duration extends MappedLong(this) object requestParams extends MappedString(this, 1024) object isSuccessful extends MappedBoolean(this) + object apiInstanceId extends MappedString(this, 255) override def getConnectorName(): String = connectorName.get override def getFunctionName(): String = functionName.get @@ -84,6 +88,7 @@ class MappedConnectorMetric extends ConnectorMetric with LongKeyedMapper[MappedC override def getDuration(): Long = duration.get override def getRequestParams(): String = requestParams.get override def getIsSuccessful(): Boolean = isSuccessful.get + override def getApiInstanceId(): String = apiInstanceId.get } object MappedConnectorMetric extends MappedConnectorMetric with LongKeyedMetaMapper[MappedConnectorMetric] { diff --git a/obp-api/src/main/scala/code/metrics/ConnectorMetricsProvider.scala b/obp-api/src/main/scala/code/metrics/ConnectorMetricsProvider.scala index a11a198bc2..a0b8639131 100644 --- a/obp-api/src/main/scala/code/metrics/ConnectorMetricsProvider.scala +++ b/obp-api/src/main/scala/code/metrics/ConnectorMetricsProvider.scala @@ -32,10 +32,14 @@ object ConnectorMetricsProvider extends SimpleInjector { trait ConnectorMetricsProvider { def saveConnectorMetric(connectorName: String, functionName: String, correlationId: String, date: Date, duration: Long): Unit = { - saveConnectorMetric(connectorName, functionName, correlationId, date, duration, "", true) + saveConnectorMetric(connectorName, functionName, correlationId, date, duration, "", true, code.api.Constant.ApiInstanceId) } def saveConnectorMetric(connectorName: String, functionName: String, correlationId: String, date: Date, duration: Long, - requestParams: String, isSuccessful: Boolean): Unit + requestParams: String, isSuccessful: Boolean): Unit = { + saveConnectorMetric(connectorName, functionName, correlationId, date, duration, requestParams, isSuccessful, code.api.Constant.ApiInstanceId) + } + def saveConnectorMetric(connectorName: String, functionName: String, correlationId: String, date: Date, duration: Long, + requestParams: String, isSuccessful: Boolean, apiInstanceId: String): Unit def getAllConnectorMetrics(queryParams: List[OBPQueryParam]): List[ConnectorMetric] def bulkDeleteConnectorMetrics(): Boolean @@ -50,5 +54,6 @@ trait ConnectorMetric { def getDuration(): Long def getRequestParams(): String def getIsSuccessful(): Boolean + def getApiInstanceId(): String } diff --git a/obp-api/src/main/scala/code/products/MappedProductsProvider.scala b/obp-api/src/main/scala/code/products/MappedProductsProvider.scala index e67cb4a6f0..26f6bedaff 100644 --- a/obp-api/src/main/scala/code/products/MappedProductsProvider.scala +++ b/obp-api/src/main/scala/code/products/MappedProductsProvider.scala @@ -76,4 +76,3 @@ class MappedProduct extends Product with LongKeyedMapper[MappedProduct] with IdP object MappedProduct extends MappedProduct with LongKeyedMetaMapper[MappedProduct] { override def dbIndexes = UniqueIndex(mBankId, mCode) :: Index(mBankId) :: super.dbIndexes } - diff --git a/obp-api/src/main/scala/code/products/ProductTag.scala b/obp-api/src/main/scala/code/products/ProductTag.scala new file mode 100644 index 0000000000..748fd546b1 --- /dev/null +++ b/obp-api/src/main/scala/code/products/ProductTag.scala @@ -0,0 +1,84 @@ +package code.products + +import code.util.UUIDString +import com.openbankproject.commons.model.{BankId, ProductCode} +import net.liftweb.common.Box +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo + +// Product tags keyed by (bank_id, product_code, tag). No FK to MappedProduct so tags work for +// connector-sourced products that have no local row. +class ProductTag extends LongKeyedMapper[ProductTag] with IdPK { + override def getSingleton = ProductTag + + object BankId extends UUIDString(this) + object ProductCode extends MappedString(this, 50) + object Tag extends MappedString(this, 100) +} + +object ProductTag extends ProductTag with LongKeyedMetaMapper[ProductTag] { + override def dbIndexes = + UniqueIndex(BankId, ProductCode, Tag) :: + Index(BankId, ProductCode) :: + super.dbIndexes +} + +// Normalisation and CRUD for product tags. Replace-semantics on setTags: diff old vs new rather +// than truncate + insert, so concurrent updates of disjoint tags stay race-free at the row level. +object ProductTagsProvider { + + private def normalise(tags: List[String]): List[String] = + tags.map(_.trim.toLowerCase).filter(_.nonEmpty).distinct + + def getTags(bankId: BankId, productCode: ProductCode): List[String] = { + ProductTag.findAll( + By(ProductTag.BankId, bankId.value), + By(ProductTag.ProductCode, productCode.value) + ).map(_.Tag.get).sorted + } + + def setTags(bankId: BankId, productCode: ProductCode, tags: List[String]): Box[List[String]] = tryo { + val desired = normalise(tags).toSet + val existing = ProductTag.findAll( + By(ProductTag.BankId, bankId.value), + By(ProductTag.ProductCode, productCode.value) + ) + val existingByTag: Map[String, ProductTag] = existing.map(t => t.Tag.get -> t).toMap + + val toDelete = existing.filterNot(t => desired.contains(t.Tag.get)) + val toAdd = desired.filterNot(existingByTag.contains) + + toDelete.foreach(_.delete_!) + toAdd.foreach { tag => + ProductTag.create + .BankId(bankId.value) + .ProductCode(productCode.value) + .Tag(tag) + .saveMe() + } + desired.toList.sorted + } + + // AND semantics: returns product codes that carry EVERY requested tag. + def getProductCodesWithAllTags(bankId: BankId, tags: List[String]): Set[String] = { + val normalised = normalise(tags) + if (normalised.isEmpty) return Set.empty + val perTag: List[Set[String]] = normalised.map { t => + ProductTag.findAll( + By(ProductTag.BankId, bankId.value), + By(ProductTag.Tag, t) + ).map(_.ProductCode.get).toSet + } + perTag.reduce(_ intersect _) + } + + // Batch lookup for list endpoints — one query returns all (code -> tags) for the bank. + def getTagsByProductCodes(bankId: BankId, productCodes: List[String]): Map[String, List[String]] = { + if (productCodes.isEmpty) return Map.empty + val rows = ProductTag.findAll( + By(ProductTag.BankId, bankId.value), + ByList(ProductTag.ProductCode, productCodes) + ) + rows.groupBy(_.ProductCode.get).map { case (code, ts) => code -> ts.map(_.Tag.get).sorted } + } +}