From 8016cc069a4b985832d51ff55ad48042ca5c9289 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 00:48:54 +0100 Subject: [PATCH 1/8] test: expect v7.0.0 customers at a bank to filter by customer attributes --- .../api/v7_0_0/JSONFactory700Customers.scala | 31 ++++ .../v7_0_0/CustomersAtOneBankV700Test.scala | 144 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700Customers.scala create mode 100644 obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700Customers.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700Customers.scala new file mode 100644 index 0000000000..5b7f491610 --- /dev/null +++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700Customers.scala @@ -0,0 +1,31 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, 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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) +*/ +package code.api.v7_0_0 + +import code.api.v6_0_0.CustomerWithAttributesJsonV600 + +/** The Customers of a bank, each with its Customer Attributes. */ +case class CustomersWithAttributesJsonV700(customers: List[CustomerWithAttributesJsonV600]) diff --git a/obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala b/obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala new file mode 100644 index 0000000000..ea76033fd6 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala @@ -0,0 +1,144 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, 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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) +*/ +package code.api.v7_0_0 + +import java.util.Date + +import code.api.util.APIUtil.OAuth._ +import code.api.util.APIUtil.generateUUID +import code.api.util.ApiRole.CanGetCustomersAtOneBank +import code.api.util.ErrorMessages._ +import code.api.v6_0_0.V600ServerSetup +import code.customer.CustomerX +import code.customerattribute.CustomerAttributeX +import code.entitlement.Entitlement +import com.openbankproject.commons.model.enums.CustomerAttributeType +import com.openbankproject.commons.model.{BankId, CustomerFaceImage, CustomerId, ErrorMessage} +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag + +import scala.concurrent.Await +import scala.concurrent.duration._ + +class CustomersAtOneBankV700Test extends V600ServerSetup { + + def v7 = baseRequest / "obp" / "v7.0.0" + object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString) + object CustomersAtOneBank extends Tag("getCustomersAtOneBank") + + private lazy val bankId = testBankId1.value + + private def withRole[T](f: => T): T = { + val grant = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanGetCustomersAtOneBank.toString) + try f finally Entitlement.entitlement.vend.deleteEntitlement(grant) + } + + /** A fresh Customer of the test bank with the given attributes; returns its id. */ + private def customer(attributes: (String, String)*): String = { + val created = CustomerX.customerProvider.vend.addCustomer( + BankId(bankId), generateUUID().take(12), s"Organisation ${generateUUID().take(6)}", "+968 1234 5678", "org@example.com", + CustomerFaceImage(new Date(), "http://example.com/face.png"), + new Date(), "single", 0, Nil, "Bachelor", "employed", false, new Date(), + None, None, "", "", "", "CORPORATE", "" + ).openOrThrowException("expected the customer to be created") + attributes.foreach { case (name, value) => + Await.result(CustomerAttributeX.customerAttributeProvider.vend.createOrUpdateCustomerAttribute( + BankId(bankId), CustomerId(created.customerId), None, name, CustomerAttributeType.STRING, value), 10.seconds) + .openOrThrowException("expected the attribute to be created") + } + created.customerId + } + + /** Query parameters go into the URL; a name may repeat. */ + private def get(query: (String, String)*) = { + val request = query.foldLeft((v7 / "banks" / bankId / "customers").GET) { case (r, (k, v)) => r.addQueryParameter(k, v) } + makeGetRequest(request <@ (user1)) + } + + private def ids(query: (String, String)*): List[String] = { + val response = withRole { get(query: _*) } + withClue(response.body) { response.code should equal(200) } + response.body.extract[CustomersWithAttributesJsonV700].customers.map(_.customer_id) + } + + feature(s"Get Customers at Bank, filtered by Customer Attributes - GET /banks/BANK_ID/customers - $VersionOfApi") { + + scenario("It needs the Role", CustomersAtOneBank, VersionOfApi) { + val response = get() + response.code should equal(403) + response.body.extract[ErrorMessage].message should include(UserHasMissingRoles) + } + + scenario("Without attribute parameters it lists the bank's Customers with their attributes", CustomersAtOneBank, VersionOfApi) { + val kind = s"university-${generateUUID().take(6)}" + val customerId = customer("organisation_kind" -> kind) + val response = withRole { get("limit" -> "1000") } + withClue(response.body) { response.code should equal(200) } + And("it is served by v7.0.0 itself, not passed on to v6.0.0") + response.headers.flatMap(h => Option(h.get("X-OBP-Version-Served"))) should equal(None) + val listed = response.body.extract[CustomersWithAttributesJsonV700].customers.find(_.customer_id == customerId) + listed.map(_.customer_attributes.map(a => a.name -> a.value)) should equal(Some(List("organisation_kind" -> kind))) + } + + scenario("An attribute parameter returns only the Customers that have that value", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val university = customer("organisation_kind" -> s"university-$tag") + customer("organisation_kind" -> s"regulator-$tag") + customer() + ids("organisation_kind" -> s"university-$tag") should equal(List(university)) + ids("organisation_kind" -> s"nothing-$tag") should equal(Nil) + } + + scenario("Comma-separated values match any of them, different names must all match", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val university = customer("organisation_kind" -> s"university-$tag", "city" -> s"muscat-$tag") + val regulator = customer("organisation_kind" -> s"regulator-$tag", "city" -> s"sohar-$tag") + customer("organisation_kind" -> s"partner-$tag", "city" -> s"muscat-$tag") + + ids("organisation_kind" -> s"university-$tag,regulator-$tag").toSet should equal(Set(university, regulator)) + ids("organisation_kind" -> s"university-$tag", "city" -> s"muscat-$tag") should equal(List(university)) + ids("organisation_kind" -> s"regulator-$tag", "city" -> s"muscat-$tag") should equal(Nil) + } + + scenario("A query parameter name given twice is refused, as on every endpoint", CustomersAtOneBank, VersionOfApi) { + val response = withRole { get("organisation_kind" -> "university", "organisation_kind" -> "regulator") } + response.code should equal(400) + response.body.extract[ErrorMessage].message should include(DuplicateQueryParameters) + } + + scenario("limit and offset page through the matching Customers only", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val matching = Set(customer("cohort" -> s"c-$tag"), customer("cohort" -> s"c-$tag")) + customer("cohort" -> s"other-$tag") + val first = ids("cohort" -> s"c-$tag", "limit" -> "1", "offset" -> "0") + val second = ids("cohort" -> s"c-$tag", "limit" -> "1", "offset" -> "1") + first.size should equal(1) + second.size should equal(1) + (first ++ second).toSet should equal(matching) + ids("cohort" -> s"c-$tag", "limit" -> "1", "offset" -> "2") should equal(Nil) + } + } +} From a81d83b0bfd4456bf7df208961569ff233ab9640 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 00:49:55 +0100 Subject: [PATCH 2/8] feat: filter v7.0.0 customers at a bank by customer attributes GET /obp/v7.0.0/banks/BANK_ID/customers returns each Customer with its Customer Attributes. Query parameters other than limit, offset and sort_direction filter on attributes: comma-separated values match any of them, different names must all match. --- .../scala/code/api/v7_0_0/Http4s700.scala | 3 + .../code/api/v7_0_0/Http4s700Customers.scala | 157 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 90157ceaf3..b47f3766bb 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -7728,6 +7728,9 @@ object Http4s700 { // Consumer account access: a view of an account granted to a Consumer, for an application acting as itself. resourceDocs ++= Http4s700ConsumerAccountAccess.resourceDocs + // Customers at a bank, each with its Customer Attributes, filtered by Customer Attributes. + resourceDocs ++= Http4s700Customers.resourceDocs + // The order in which the routes are tried. ResourceDocMiddleware selects the doc of the first // route that serves a request, so it is given this same list: the doc that validates a request // is the doc of the route that runs it. diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala new file mode 100644 index 0000000000..2dd9f74288 --- /dev/null +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala @@ -0,0 +1,157 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, 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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) +*/ +package code.api.v7_0_0 + +import code.api.Constant.ApiPathZero +import code.api.util.APIUtil.{EmptyBody, Http4sRoute, ResourceDoc, userAuthenticationMessage} +import code.api.util.ApiRole.canGetCustomersAtOneBank +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers +import code.api.util.{CallContext, CustomJsonFormats, NewStyle, OBPAscending, OBPLimit, OBPOffset, OBPOrdering, OBPQueryParam} +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.customerWithAttributesJsonV600 +import code.api.v6_0_0.JSONFactory600 +import code.bankconnectors.Connector +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.{BankId, Customer, CustomerId} +import com.openbankproject.commons.util.ApiVersion +import org.http4s._ +import org.http4s.dsl.io._ +import org.json4s.Formats + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +object Http4s700Customers { + + implicit val formats: Formats = CustomJsonFormats.formats + + private val implementedInApiVersion = ApiVersion.v7_0_0 + private val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + + val resourceDocs = ArrayBuffer[ResourceDoc]() + + /** The query parameters that page and sort the list. Every other query parameter filters on a Customer Attribute. */ + val PagingParameters: List[String] = List("limit", "offset", "sort_direction") + + /** + * name -> accepted values, from the query parameters that are not paging parameters. A value may + * list several accepted values separated by commas (a name cannot be repeated: the middleware + * refuses duplicate query parameter names on every endpoint). + */ + def attributeFilters(query: Map[String, List[String]]): Map[String, List[String]] = + query.collect { case (name, values) if !PagingParameters.contains(name) => + name -> values.flatMap(_.split(",").map(_.trim)).filter(_.nonEmpty).distinct + }.filter(_._2.nonEmpty) + + /** + * The bank's Customers that have, for every filter name, an attribute of that name with one of + * its values. Each name is asked of the connector on its own and the results are intersected, so + * the rule does not depend on how a connector combines several names. + */ + private def customersMatching(bankId: BankId, filters: Map[String, List[String]], cc: CallContext): Future[List[Customer]] = + for { + idsPerName <- Future.sequence(filters.toList.map { case (name, values) => + NewStyle.function.getCustomerIdsByAttributeNameValues(bankId, Map(name -> values), Some(cc)).map(_._1.map(_.value).toSet) + }) + ids = idsPerName.reduceOption(_ intersect _).getOrElse(Set.empty[String]) + found <- Future.sequence(ids.toList.map(id => Connector.connector.vend.getCustomerByCustomerId(id, Some(cc)).map(_.map(_._1).toOption))) + } yield found.flatten.filter(_.bankId == bankId.value) + + /** The same ordering and page the provider applies to an unfiltered list: last_ok_date, then customer_id. */ + private def page(customers: List[Customer], queryParams: List[OBPQueryParam]): List[Customer] = { + val byId = customers.sortBy(_.customerId) + val ordered = queryParams.collectFirst { case OBPOrdering(_, order) => order } match { + case Some(OBPAscending) => byId.sortBy(c => Option(c.lastOkDate).map(_.getTime).getOrElse(0L)) + case Some(_) => byId.sortBy(c => -Option(c.lastOkDate).map(_.getTime).getOrElse(0L)) + case None => byId + } + val offset = queryParams.collectFirst { case OBPOffset(value) => value }.getOrElse(0) + val limit = queryParams.collectFirst { case OBPLimit(value) => value } + val rest = ordered.drop(math.max(offset, 0)) + limit.map(l => rest.take(math.max(l, 0))).getOrElse(rest) + } + + // Route: GET /obp/v7.0.0/banks/BANK_ID/customers + lazy val getCustomersAtOneBank: Http4sRoute = Http4sRoute { + case req @ GET -> `prefixPath` / "banks" / _ / "customers" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + val filters = attributeFilters(req.uri.query.multiParams.map { case (k, vs) => k -> vs.toList }) + for { + (queryParams, _) <- NewStyle.function.extractQueryParams(req.uri.renderString, PagingParameters, Some(cc)) + customers <- + if (filters.isEmpty) NewStyle.function.getCustomers(bank.bankId, Some(cc), queryParams) + else customersMatching(bank.bankId, filters, cc).map(page(_, queryParams)) + withAttributes <- Future.sequence(customers.map { customer => + NewStyle.function.getCustomerAttributes(bank.bankId, CustomerId(customer.customerId), Some(cc)) + .map { case (attributes, _) => JSONFactory600.createCustomerWithAttributesJson(customer, attributes) } + }) + } yield CustomersWithAttributesJsonV700(withAttributes) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getCustomersAtOneBank), + "GET", + "/banks/BANK_ID/customers", + "Get Customers at Bank", + s"""Get the Customers at the bank, each with its Customer Attributes, optionally filtered by Customer Attributes. + | + |**Filtering by Customer Attributes:** every query parameter other than the paging parameters below + |is the name of a Customer Attribute, and its value the attribute value a Customer must have. + |Several accepted values are separated by commas; different names must all match. + |A name can be given only once (as on every endpoint, a repeated query parameter name is refused). + | + |Examples: + | + |* `?organisation_kind=university` returns the Customers whose attribute `organisation_kind` is `university`. + |* `?organisation_kind=university,regulator` returns those whose `organisation_kind` is either. + |* `?organisation_kind=university&city=Muscat` returns those with both attributes. + | + |Without attribute parameters all Customers at the bank are returned. + | + |**Paging parameters:** + | + |* limit: maximum number of Customers to return (optional) + |* offset: number of Customers to skip (optional) + |* sort_direction: ASC or DESC, by last_ok_date (optional) + | + |When attributes are filtered, limit and offset count the matching Customers only. + | + |Dates are returned as **YYYY-MM-DD**. + | + |${userAuthenticationMessage(true)} + |""".stripMargin, + EmptyBody, + CustomersWithAttributesJsonV700(List(customerWithAttributesJsonV600)), + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagCustomer, apiTagUser), + Some(canGetCustomersAtOneBank :: Nil), + http4sPartialFunction = Some(getCustomersAtOneBank) + ) +} From 30fa080c9b88b7b38e55f3353814dac5b0919f7b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 02:07:50 +0100 Subject: [PATCH 3/8] test: cover cross-bank, empty values, reserved parameters and ordering of v7.0.0 customers --- .../v7_0_0/CustomersAtOneBankV700Test.scala | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala b/obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala index ea76033fd6..6e61edc189 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala @@ -57,16 +57,18 @@ class CustomersAtOneBankV700Test extends V600ServerSetup { } /** A fresh Customer of the test bank with the given attributes; returns its id. */ - private def customer(attributes: (String, String)*): String = { + private def customer(attributes: (String, String)*): String = customerAt(bankId, attributes: _*) + + private def customerAt(bank: String, attributes: (String, String)*): String = { val created = CustomerX.customerProvider.vend.addCustomer( - BankId(bankId), generateUUID().take(12), s"Organisation ${generateUUID().take(6)}", "+968 1234 5678", "org@example.com", + BankId(bank), generateUUID().take(12), s"Organisation ${generateUUID().take(6)}", "+968 1234 5678", "org@example.com", CustomerFaceImage(new Date(), "http://example.com/face.png"), new Date(), "single", 0, Nil, "Bachelor", "employed", false, new Date(), None, None, "", "", "", "CORPORATE", "" ).openOrThrowException("expected the customer to be created") attributes.foreach { case (name, value) => Await.result(CustomerAttributeX.customerAttributeProvider.vend.createOrUpdateCustomerAttribute( - BankId(bankId), CustomerId(created.customerId), None, name, CustomerAttributeType.STRING, value), 10.seconds) + BankId(bank), CustomerId(created.customerId), None, name, CustomerAttributeType.STRING, value), 10.seconds) .openOrThrowException("expected the attribute to be created") } created.customerId @@ -86,6 +88,46 @@ class CustomersAtOneBankV700Test extends V600ServerSetup { feature(s"Get Customers at Bank, filtered by Customer Attributes - GET /banks/BANK_ID/customers - $VersionOfApi") { + scenario("Customers of another bank are never listed, and the Role is needed at this bank", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val here = customer("organisation_kind" -> s"university-$tag") + customerAt(testBankId2.value, "organisation_kind" -> s"university-$tag") + ids("organisation_kind" -> s"university-$tag") should equal(List(here)) + + val grantAtOtherBank = Entitlement.entitlement.vend.addEntitlement(testBankId2.value, resourceUser1.userId, CanGetCustomersAtOneBank.toString) + val response = try get("organisation_kind" -> s"university-$tag") finally Entitlement.entitlement.vend.deleteEntitlement(grantAtOtherBank) + response.code should equal(403) + } + + scenario("An attribute parameter without a value is refused", CustomersAtOneBank, VersionOfApi) { + Seq("", ",", " , ").foreach { value => + val response = withRole { get("organisation_kind" -> value) } + withClue(s"value '$value': ${response.body}") { response.code should equal(400) } + response.body.extract[ErrorMessage].message should include(InvalidUrlParameters) + } + } + + scenario("Other OBP query parameters are not taken for attribute names", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val matching = customer("organisation_kind" -> s"bank-$tag") + ids("organisation_kind" -> s"bank-$tag", "from_date" -> "2020-01-01T00:00:00.000Z", "to_date" -> "2099-01-01T00:00:00.000Z", + "sort_by" -> "name", "obp_limit" -> "10", "_" -> "123") should equal(List(matching)) + } + + scenario("sort_direction orders the matching Customers by customer_id", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val created = (1 to 3).map(_ => customer("cohort" -> s"s-$tag")).toList + ids("cohort" -> s"s-$tag", "sort_direction" -> "ASC") should equal(created.sorted) + ids("cohort" -> s"s-$tag", "sort_direction" -> "DESC") should equal(created.sorted.reverse) + } + + scenario("Without attribute parameters limit and offset still page the list", CustomersAtOneBank, VersionOfApi) { + customer("cohort" -> s"p-${generateUUID().take(6)}") + customer("cohort" -> s"p-${generateUUID().take(6)}") + ids("limit" -> "1") should have size 1 + ids("limit" -> "1", "offset" -> "1") should have size 1 + } + scenario("It needs the Role", CustomersAtOneBank, VersionOfApi) { val response = get() response.code should equal(403) From e9d43b7357ab67ba5bdd8670dc2f8ed9e077cea0 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 02:08:32 +0100 Subject: [PATCH 4/8] fix: page v7.0.0 attribute-filtered customers before loading them and validate the filters Only the requested page of matching Customers is loaded, ordered by customer_id; attributes are read in one connector call; reserved OBP query parameters are not taken for attribute names; a filter without a value is refused with OBP-10034; a connector failure fails the request. --- .../code/api/v7_0_0/Http4s700Customers.scala | 117 ++++++++++-------- 1 file changed, 67 insertions(+), 50 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala index 2dd9f74288..a83d1ea09d 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala @@ -31,13 +31,15 @@ import code.api.util.ApiRole.canGetCustomersAtOneBank import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers -import code.api.util.{CallContext, CustomJsonFormats, NewStyle, OBPAscending, OBPLimit, OBPOffset, OBPOrdering, OBPQueryParam} +import code.api.util.{APIUtil, CallContext, CustomJsonFormats, NewStyle, OBPAscending, OBPLimit, OBPOffset, OBPOrdering, OBPQueryParam} +import code.util.Helper +import net.liftweb.common.{Empty, Failure, Full} import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.customerWithAttributesJsonV600 import code.api.v6_0_0.JSONFactory600 import code.bankconnectors.Connector import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global -import com.openbankproject.commons.model.{BankId, Customer, CustomerId} +import com.openbankproject.commons.model.{BankId, Customer} import com.openbankproject.commons.util.ApiVersion import org.http4s._ import org.http4s.dsl.io._ @@ -55,45 +57,54 @@ object Http4s700Customers { val resourceDocs = ArrayBuffer[ResourceDoc]() - /** The query parameters that page and sort the list. Every other query parameter filters on a Customer Attribute. */ + /** The query parameters that page and sort the list. */ val PagingParameters: List[String] = List("limit", "offset", "sort_direction") /** - * name -> accepted values, from the query parameters that are not paging parameters. A value may - * list several accepted values separated by commas (a name cannot be repeated: the middleware - * refuses duplicate query parameter names on every endpoint). + * Query parameters that are never taken for attribute names: the paging parameters, the other + * parameters OBP-API reads on list endpoints, and names starting with obp_ or _ (cache busters). + */ + private val ReservedParameters: Set[String] = PagingParameters.toSet ++ Set("from_date", "to_date", "sort_by") + private def reserved(name: String): Boolean = ReservedParameters.contains(name) || name.startsWith("obp_") || name.startsWith("_") + + /** + * name -> accepted values, from the query parameters that are not reserved. A value lists several + * accepted values separated by commas (a name cannot be repeated: the middleware refuses duplicate + * query parameter names on every endpoint). A name whose values are all empty maps to Nil. */ def attributeFilters(query: Map[String, List[String]]): Map[String, List[String]] = - query.collect { case (name, values) if !PagingParameters.contains(name) => + query.collect { case (name, values) if !reserved(name) => name -> values.flatMap(_.split(",").map(_.trim)).filter(_.nonEmpty).distinct - }.filter(_._2.nonEmpty) + } /** - * The bank's Customers that have, for every filter name, an attribute of that name with one of - * its values. Each name is asked of the connector on its own and the results are intersected, so - * the rule does not depend on how a connector combines several names. + * The ids of the bank's Customers that have, for every filter name, an attribute of that name with + * one of its values. Each name is asked of the connector on its own and the results are + * intersected, so the rule does not depend on how a connector combines several names. */ - private def customersMatching(bankId: BankId, filters: Map[String, List[String]], cc: CallContext): Future[List[Customer]] = - for { - idsPerName <- Future.sequence(filters.toList.map { case (name, values) => - NewStyle.function.getCustomerIdsByAttributeNameValues(bankId, Map(name -> values), Some(cc)).map(_._1.map(_.value).toSet) - }) - ids = idsPerName.reduceOption(_ intersect _).getOrElse(Set.empty[String]) - found <- Future.sequence(ids.toList.map(id => Connector.connector.vend.getCustomerByCustomerId(id, Some(cc)).map(_.map(_._1).toOption))) - } yield found.flatten.filter(_.bankId == bankId.value) - - /** The same ordering and page the provider applies to an unfiltered list: last_ok_date, then customer_id. */ - private def page(customers: List[Customer], queryParams: List[OBPQueryParam]): List[Customer] = { - val byId = customers.sortBy(_.customerId) - val ordered = queryParams.collectFirst { case OBPOrdering(_, order) => order } match { - case Some(OBPAscending) => byId.sortBy(c => Option(c.lastOkDate).map(_.getTime).getOrElse(0L)) - case Some(_) => byId.sortBy(c => -Option(c.lastOkDate).map(_.getTime).getOrElse(0L)) - case None => byId - } - val offset = queryParams.collectFirst { case OBPOffset(value) => value }.getOrElse(0) - val limit = queryParams.collectFirst { case OBPLimit(value) => value } - val rest = ordered.drop(math.max(offset, 0)) - limit.map(l => rest.take(math.max(l, 0))).getOrElse(rest) + private def matchingCustomerIds(bankId: BankId, filters: Map[String, List[String]], cc: CallContext): Future[Set[String]] = + Future.sequence(filters.toList.map { case (name, values) => + NewStyle.function.getCustomerIdsByAttributeNameValues(bankId, Map(name -> values), Some(cc)).map(_._1.map(_.value).toSet) + }).map(_.reduceOption(_ intersect _).getOrElse(Set.empty[String])) + + /** + * One page of the matching Customers, ordered by customer_id (sort_direction ASC or DESC). Only the + * page is loaded. An attribute whose Customer no longer exists is skipped; a connector failure fails + * the request. + */ + private def pageOfMatchingCustomers(bankId: BankId, ids: Set[String], queryParams: List[OBPQueryParam], cc: CallContext): Future[List[Customer]] = { + val ascending = queryParams.collectFirst { case OBPOrdering(_, order) => order }.forall(_ == OBPAscending) + val ordered = if (ascending) ids.toList.sorted else ids.toList.sorted.reverse + val offset = math.max(queryParams.collectFirst { case OBPOffset(value) => value }.getOrElse(0), 0) + val limit = queryParams.collectFirst { case OBPLimit(value) => math.max(value, 0) } + val pageIds = limit.fold(ordered.drop(offset))(l => ordered.slice(offset, offset + l)) + Future.sequence(pageIds.map { id => + Connector.connector.vend.getCustomerByCustomerId(id, Some(cc)).map { + case Full((customer, _)) => Some(customer) + case Empty => None + case failure: Failure => Some(APIUtil.connectorEmptyResponse[Customer](failure, Some(cc))) + } + }).map(_.flatten.filter(_.bankId == bankId.value)) } // Route: GET /obp/v7.0.0/banks/BANK_ID/customers @@ -101,16 +112,19 @@ object Http4s700Customers { case req @ GET -> `prefixPath` / "banks" / _ / "customers" => EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => val filters = attributeFilters(req.uri.query.multiParams.map { case (k, vs) => k -> vs.toList }) + val withoutValue = filters.collect { case (name, Nil) => name }.toList.sorted for { + _ <- Helper.booleanToFuture(s"$InvalidUrlParameters Customer Attribute filters need a value: ${withoutValue.mkString(", ")}", 400, Some(cc)) { + withoutValue.isEmpty + } (queryParams, _) <- NewStyle.function.extractQueryParams(req.uri.renderString, PagingParameters, Some(cc)) customers <- if (filters.isEmpty) NewStyle.function.getCustomers(bank.bankId, Some(cc), queryParams) - else customersMatching(bank.bankId, filters, cc).map(page(_, queryParams)) - withAttributes <- Future.sequence(customers.map { customer => - NewStyle.function.getCustomerAttributes(bank.bankId, CustomerId(customer.customerId), Some(cc)) - .map { case (attributes, _) => JSONFactory600.createCustomerWithAttributesJson(customer, attributes) } - }) - } yield CustomersWithAttributesJsonV700(withAttributes) + else matchingCustomerIds(bank.bankId, filters, cc).flatMap(pageOfMatchingCustomers(bank.bankId, _, queryParams, cc)) + (withAttributes, _) <- + if (customers.isEmpty) Future.successful((Nil, Some(cc))) + else NewStyle.function.getCustomerAttributesForCustomers(customers, Some(cc)) + } yield CustomersWithAttributesJsonV700(withAttributes.map(ca => JSONFactory600.createCustomerWithAttributesJson(ca.customer, ca.attributes))) } } @@ -122,10 +136,13 @@ object Http4s700Customers { "Get Customers at Bank", s"""Get the Customers at the bank, each with its Customer Attributes, optionally filtered by Customer Attributes. | - |**Filtering by Customer Attributes:** every query parameter other than the paging parameters below - |is the name of a Customer Attribute, and its value the attribute value a Customer must have. - |Several accepted values are separated by commas; different names must all match. - |A name can be given only once (as on every endpoint, a repeated query parameter name is refused). + |**Filtering by Customer Attributes:** a query parameter is the name of a Customer Attribute and its value + |the attribute value a Customer must have. Several accepted values are separated by commas, so a value + |cannot itself contain a comma; different names must all match. A name can be given only once (as on every + |endpoint, a repeated query parameter name is refused), and a filter without a value is refused. + | + |Not taken for attribute names: limit, offset, sort_direction, from_date, to_date, sort_by, and names + |starting with obp_ or _. | |Examples: | @@ -133,15 +150,15 @@ object Http4s700Customers { |* `?organisation_kind=university,regulator` returns those whose `organisation_kind` is either. |* `?organisation_kind=university&city=Muscat` returns those with both attributes. | - |Without attribute parameters all Customers at the bank are returned. - | - |**Paging parameters:** + |**Paging:** | - |* limit: maximum number of Customers to return (optional) - |* offset: number of Customers to skip (optional) - |* sort_direction: ASC or DESC, by last_ok_date (optional) + |* limit: maximum number of Customers to return (default 50) + |* offset: number of Customers to skip (default 0) + |* sort_direction: ASC or DESC (default DESC) | - |When attributes are filtered, limit and offset count the matching Customers only. + |Without attribute filters the Customers at the bank are ordered by last_ok_date, as in v6.0.0. With + |attribute filters the matching Customers are ordered by customer_id, and limit and offset count the + |matching Customers only. | |Dates are returned as **YYYY-MM-DD**. | @@ -149,7 +166,7 @@ object Http4s700Customers { |""".stripMargin, EmptyBody, CustomersWithAttributesJsonV700(List(customerWithAttributesJsonV600)), - List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvalidUrlParameters, UnknownError), List(apiTagCustomer, apiTagUser), Some(canGetCustomersAtOneBank :: Nil), http4sPartialFunction = Some(getCustomersAtOneBank) From ee6934c4aa95a5bd11184136aede61cb8ef5e236 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 02:08:32 +0100 Subject: [PATCH 5/8] docs: note that getCustomersAtOneBank is native in v7.0.0 again --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4e6554936a..16d2b40b16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ The goal is a full http4s migration — replace Lift Web across all version file **Key files**: `Http4s700.scala` (v7.0.0 endpoints), `Http4s200.scala` (v2.0.0 endpoints — 37 own + path-rewriting bridge to Http4s140), `Http4s140.scala` (v1.4.0 endpoints — 11 own + path-rewriting bridge to Http4s130), `Http4s130.scala` (v1.3.0 endpoints — 3 own + path-rewriting bridge to Http4s121), `Http4s121.scala` (v1.2.1 endpoints — all 323 API1_2_1Test scenarios), `Http4sSupport.scala` (EndpointHelpers + recordMetric), `ResourceDocMiddleware.scala` (auth, entity resolution, transaction wrapper), `IdempotencyMiddleware.scala` (Redis-backed idempotency, opt-in via `Idempotency-Key` header, nested inside ResourceDocMiddleware), `RequestScopeConnection.scala` (DB transaction propagation to Futures). -**v7.0.0 native endpoints** (117 ResourceDocs). The authoritative list is the `resourceDocs += ResourceDoc(...)` calls in `Http4s700.scala` — read them there rather than from a copy in this file, which is what went stale before: this paragraph used to enumerate 49 names when the file held more than twice that. These carry genuinely v7-specific signatures/behaviour. The 20 duplicate "POC" endpoints originally added as migration scaffolding (getBanks, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, getFeatures, getScannedApiVersions, getConnectors, getProviders, getUsers, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces) were **removed** — they cascade to their v6 twin via `v700ToV600Bridge` (getExplicitCounterpartyById → v4, no v6/v5 twin), `X-OBP-Version-Served: v6.0.0`. One of those 20 has since come back: `getCurrentUser` is native in v7 again, because its response gained genuinely v7-specific content (the caller's own mobile phone fields, and `on_behalf_of`). Kept deliberately in v7: `deleteEntitlement` (204), `addEntitlement` (409), `getUserByUserId` (404) — intentional RESTful response-code improvements over the older v6 200/400 convention. +**v7.0.0 native endpoints** (117 ResourceDocs). The authoritative list is the `resourceDocs += ResourceDoc(...)` calls in `Http4s700.scala` — read them there rather than from a copy in this file, which is what went stale before: this paragraph used to enumerate 49 names when the file held more than twice that. These carry genuinely v7-specific signatures/behaviour. The 20 duplicate "POC" endpoints originally added as migration scaffolding (getBanks, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, getFeatures, getScannedApiVersions, getConnectors, getProviders, getUsers, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces) were **removed** — they cascade to their v6 twin via `v700ToV600Bridge` (getExplicitCounterpartyById → v4, no v6/v5 twin), `X-OBP-Version-Served: v6.0.0`. Two of those 20 have since come back: `getCurrentUser` is native in v7 again, because its response gained genuinely v7-specific content (the caller's own mobile phone fields, and `on_behalf_of`), and `getCustomersAtOneBank` (`Http4s700Customers.scala`), because it filters by Customer Attributes and returns each Customer's `customer_attributes`. Kept deliberately in v7: `deleteEntitlement` (204), `addEntitlement` (409), `getUserByUserId` (404) — intentional RESTful response-code improvements over the older v6 200/400 convention. **Tests**: `Http4s700RoutesTest` (179 scenarios). The suite drives `Http4s700.wrappedRoutesV700Services.orNotFound` in-process; it starts no server and listens on no port. `makeHttpRequest(path, headers)` returns `(Int, JValue, Map[String, String])` and is GET-only; `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT; `makeHttpRequestWithMethod(method, path, headers)` for a verb with no body, such as DELETE. ## Migrating a Lift Endpoint to http4s From eb7e0ea703f1873056fb3ca8dc709e18f5ec6cbb Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 02:13:22 +0100 Subject: [PATCH 6/8] test: pin ordering, paging, invalid paging, reserved names in any case and dangling attributes of v7.0.0 customers --- .../v7_0_0/CustomersAtOneBankV700Test.scala | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala b/obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala index 6e61edc189..98527c51ce 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala @@ -114,11 +114,34 @@ class CustomersAtOneBankV700Test extends V600ServerSetup { "sort_by" -> "name", "obp_limit" -> "10", "_" -> "123") should equal(List(matching)) } - scenario("sort_direction orders the matching Customers by customer_id", CustomersAtOneBank, VersionOfApi) { + scenario("sort_direction orders the matching Customers by customer_id, DESC by default, and pages follow that order", CustomersAtOneBank, VersionOfApi) { val tag = generateUUID().take(6) - val created = (1 to 3).map(_ => customer("cohort" -> s"s-$tag")).toList - ids("cohort" -> s"s-$tag", "sort_direction" -> "ASC") should equal(created.sorted) - ids("cohort" -> s"s-$tag", "sort_direction" -> "DESC") should equal(created.sorted.reverse) + val created = (1 to 3).map(_ => customer("cohort" -> s"s-$tag")).toList.sorted + ids("cohort" -> s"s-$tag", "sort_direction" -> "ASC") should equal(created) + ids("cohort" -> s"s-$tag", "sort_direction" -> "DESC") should equal(created.reverse) + ids("cohort" -> s"s-$tag") should equal(created.reverse) + ids("cohort" -> s"s-$tag", "sort_direction" -> "ASC", "limit" -> "2", "offset" -> "0") should equal(created.take(2)) + ids("cohort" -> s"s-$tag", "sort_direction" -> "ASC", "limit" -> "2", "offset" -> "2") should equal(created.drop(2)) + } + + scenario("Reserved names in another case are not taken for attribute names, and invalid paging is refused", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val created = (1 to 2).map(_ => customer("cohort" -> s"c-$tag")).toList + // As in v6.0.0 only lower-case paging names page the list; LIMIT is ignored, not an attribute filter. + ids("cohort" -> s"c-$tag", "LIMIT" -> "1", "Sort_Direction" -> "ASC").toSet should equal(created.toSet) + Seq("limit" -> "abc", "offset" -> "-1", "sort_direction" -> "sideways").foreach { bad => + val response = withRole { get("cohort" -> s"c-$tag", bad) } + withClue(s"$bad: ${response.body}") { response.code should equal(400) } + } + } + + scenario("An attribute whose Customer no longer exists is skipped", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val existing = customer("cohort" -> s"d-$tag") + Await.result(CustomerAttributeX.customerAttributeProvider.vend.createOrUpdateCustomerAttribute( + BankId(bankId), CustomerId(generateUUID()), None, "cohort", CustomerAttributeType.STRING, s"d-$tag"), 10.seconds) + .openOrThrowException("expected the attribute to be created") + ids("cohort" -> s"d-$tag") should equal(List(existing)) } scenario("Without attribute parameters limit and offset still page the list", CustomersAtOneBank, VersionOfApi) { From d115f09e94443fa6647ac463ce5228540fc1a662 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 02:14:13 +0100 Subject: [PATCH 7/8] fix: load a page of v7.0.0 filtered customers in batches and match reserved names in any case --- .../code/api/v7_0_0/Http4s700Customers.scala | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala index a83d1ea09d..805f400bce 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala @@ -65,7 +65,13 @@ object Http4s700Customers { * parameters OBP-API reads on list endpoints, and names starting with obp_ or _ (cache busters). */ private val ReservedParameters: Set[String] = PagingParameters.toSet ++ Set("from_date", "to_date", "sort_by") - private def reserved(name: String): Boolean = ReservedParameters.contains(name) || name.startsWith("obp_") || name.startsWith("_") + private def reserved(name: String): Boolean = { + val lower = name.toLowerCase + ReservedParameters.contains(lower) || lower.startsWith("obp_") || lower.startsWith("_") + } + + /** Customers of a page are loaded this many at a time, however large the page. */ + private val LoadBatchSize = 10 /** * name -> accepted values, from the query parameters that are not reserved. A value lists several @@ -98,13 +104,15 @@ object Http4s700Customers { val offset = math.max(queryParams.collectFirst { case OBPOffset(value) => value }.getOrElse(0), 0) val limit = queryParams.collectFirst { case OBPLimit(value) => math.max(value, 0) } val pageIds = limit.fold(ordered.drop(offset))(l => ordered.slice(offset, offset + l)) - Future.sequence(pageIds.map { id => + def load(id: String): Future[Option[Customer]] = Connector.connector.vend.getCustomerByCustomerId(id, Some(cc)).map { case Full((customer, _)) => Some(customer) case Empty => None case failure: Failure => Some(APIUtil.connectorEmptyResponse[Customer](failure, Some(cc))) } - }).map(_.flatten.filter(_.bankId == bankId.value)) + pageIds.grouped(LoadBatchSize).foldLeft(Future.successful(List.empty[Customer])) { (loaded, batch) => + loaded.flatMap(done => Future.sequence(batch.map(load)).map(found => done ++ found.flatten)) + }.map(_.filter(_.bankId == bankId.value)) } // Route: GET /obp/v7.0.0/banks/BANK_ID/customers @@ -141,8 +149,12 @@ object Http4s700Customers { |cannot itself contain a comma; different names must all match. A name can be given only once (as on every |endpoint, a repeated query parameter name is refused), and a filter without a value is refused. | - |Not taken for attribute names: limit, offset, sort_direction, from_date, to_date, sort_by, and names - |starting with obp_ or _. + |Not taken for attribute names, in any letter case: limit, offset, sort_direction, from_date, to_date, + |sort_by, and names starting with obp_ or _. Apart from limit, offset and sort_direction (lower case) + |they are accepted and ignored, as in v6.0.0. + | + |Values of parameters whose name ends in currency or currency_code are upper-cased by OBP-API before + |they are matched, as on every endpoint. | |Examples: | @@ -158,7 +170,9 @@ object Http4s700Customers { | |Without attribute filters the Customers at the bank are ordered by last_ok_date, as in v6.0.0. With |attribute filters the matching Customers are ordered by customer_id, and limit and offset count the - |matching Customers only. + |matching Customers only. A Customer that no longer exists but still has an attribute is left out, so + |such a page can hold fewer Customers than limit. The ids of all matching Customers are read before + |the page is chosen. | |Dates are returned as **YYYY-MM-DD**. | @@ -166,7 +180,7 @@ object Http4s700Customers { |""".stripMargin, EmptyBody, CustomersWithAttributesJsonV700(List(customerWithAttributesJsonV600)), - List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvalidUrlParameters, UnknownError), + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvalidUrlParameters, InvalidFilterParameterFormat, InvalidConnectorResponse, UnknownError), List(apiTagCustomer, apiTagUser), Some(canGetCustomersAtOneBank :: Nil), http4sPartialFunction = Some(getCustomersAtOneBank) From 18fc1cdfc6f7f5d5d90baa7d2bdf9092c848aa1f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 02:20:37 +0100 Subject: [PATCH 8/8] docs: say which currency values are upper-cased before v7.0.0 customers are filtered --- .../src/main/scala/code/api/v7_0_0/Http4s700Customers.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala index 805f400bce..7577c9ea2f 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala @@ -153,8 +153,9 @@ object Http4s700Customers { |sort_by, and names starting with obp_ or _. Apart from limit, offset and sort_direction (lower case) |they are accepted and ignored, as in v6.0.0. | - |Values of parameters whose name ends in currency or currency_code are upper-cased by OBP-API before - |they are matched, as on every endpoint. + |A single value of a parameter whose name ends in currency or currency_code that looks like a currency + |code is upper-cased by OBP-API before it is matched, as on every endpoint; comma-separated values are + |matched as given. | |Examples: |