From ad4e772d44c0ca4ec38e8d17bdd8b8aec66a9713 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 13 Oct 2026 01:59:37 +0200 Subject: [PATCH 1/3] Domain API resource docs --- .../dynamic/domainapi/Http4sDomainApi.scala | 13 ++++++ .../main/scala/code/api/util/Glossary.scala | 2 + .../code/api/v7_0_0/Http4s700DomainApis.scala | 45 +++++++++++++++++++ .../code/api/v7_0_0/DomainApisTest.scala | 21 +++++++++ 4 files changed, 81 insertions(+) diff --git a/obp-api/src/main/scala/code/api/dynamic/domainapi/Http4sDomainApi.scala b/obp-api/src/main/scala/code/api/dynamic/domainapi/Http4sDomainApi.scala index 54a3f8210b..25531a702f 100644 --- a/obp-api/src/main/scala/code/api/dynamic/domainapi/Http4sDomainApi.scala +++ b/obp-api/src/main/scala/code/api/dynamic/domainapi/Http4sDomainApi.scala @@ -117,6 +117,19 @@ object Http4sDomainApi extends MdcLoggable { } } + /** + * The space's docs as OBP Resource Docs for the Domain API: at their full published path, base path + * included, as both request_url and specified_url, so a caller such as API Explorer can call each one + * as it stands. + */ + def publishedResourceDocs(route: DomainApiRoute): List[ResourceDoc] = + publishedDocs(route).map { doc => + val url = s"/${route.basePath}${doc.requestUrl}" + val full = doc.copy(requestUrl = url, specifiedUrl = Some(url)) + full.connectorMethods = doc.connectorMethods + full + } + /** The Domain API's OpenAPI 3.1 document: the published docs, with the Domain API's own title, version and server. */ def openApi(route: DomainApiRoute): JValue = { val docsJson = JSONFactory1_4_0.createResourceDocsJson(publishedDocs(route), isVersion4OrHigher = true, locale = None).resource_docs 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 c144ad17cf..c26864ba5c 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -4361,6 +4361,8 @@ object Glossary extends MdcLoggable { | |**Discovery.** A Domain API whose `is_publicly_discoverable` is true is listed, without logging in, at `/obp/v7.0.0/banks/BANK_ID/domain-apis`, with its base path, version, title, description, URL and OpenAPI document. It is false unless set, so a Domain API is only listed publicly on purpose. It only controls that listing: the calls under the base path and the OpenAPI document are served the same either way. | +|**Resource Docs.** `/obp/v7.0.0/banks/BANK_ID/domain-apis/resource-docs?base_path=BASE_PATH` gives the same endpoints as the OpenAPI document as OBP Resource Docs, each at its full published path, for API Explorer and other OBP tools. No login is needed. +| """.stripMargin) glossaryItems += GlossaryItem( diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DomainApis.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DomainApis.scala index fa755bd2f0..ed9acf01c4 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DomainApis.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DomainApis.scala @@ -36,6 +36,8 @@ import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers import code.api.util.{CallContext, CustomJsonFormats, Glossary} +import code.api.v1_4_0.JSONFactory1_4_0 +import code.api.dynamic.domainapi.Http4sDomainApi import code.domainapi.{DomainApiDbProvider, DomainApiTrait, DomainApis} import code.util.Helper import com.github.dwickern.macros.NameOf.nameOf @@ -277,6 +279,49 @@ object Http4s700DomainApis { http4sPartialFunction = Some(getPubliclyDiscoverableDomainApis) ).allowSystemSpace() + // Route: GET /obp/v7.0.0/banks/BANK_ID/domain-apis/resource-docs?base_path=BASE_PATH (public) + lazy val getDomainApiResourceDocs: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdInUrl / "domain-apis" / "resource-docs" => + EndpointHelpers.executeAndRespond(req) { cc => + val spaceId = space(bankIdInUrl) + val basePath = req.uri.query.params.get("base_path").map(_.trim.stripPrefix("/").stripSuffix("/")).getOrElse("") + for { + _ <- Helper.booleanToFuture(s"$MissingQueryParams base_path", 400, Some(cc)) { + basePath.nonEmpty + } + route <- Future(provider.routes().find(route => route.bankId == spaceId && route.basePath == basePath)).map(found => + code.api.util.APIUtil.unboxFullOrFail(Box(found), Some(cc), s"$DomainApiNotFound Current base_path is $basePath.", 404)) + } yield JSONFactory1_4_0.createResourceDocsJson(Http4sDomainApi.publishedResourceDocs(route), isVersion4OrHigher = true, locale = None) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getDomainApiResourceDocs), + "GET", + "/banks/BANK_ID/domain-apis/resource-docs", + "Get Domain API Resource Docs", + s"""The endpoints a Domain API publishes, as OBP Resource Docs: the same endpoints as its OpenAPI document + |(`BASE_PATH/openapi.json`), in the format API Explorer and other OBP tools read. Each one's `request_url` + |and `specified_url` is its full published path, base path included (for example + |`/carbon-registry/v1/public/activity`), so it can be called as it stands. + | + |`base_path` (required) is the Domain API's base path, for example `carbon-registry/v1`. BANK_ID is its + |space: a bank's id, or `SYS` for the system space. + | + |Like the OpenAPI document, these are served whether or not the Domain API is publicly discoverable. + | + |No Authentication is Required. + | + |For more information see ${Glossary.getGlossaryItemLink("Domain APIs")}""".stripMargin, + EmptyBody, + EmptyBody, + List($BankNotFound, MissingQueryParams, DomainApiNotFound, UnknownError), + apiTagDynamic :: apiTagApi :: apiTagDocumentation :: Nil, + None, + http4sPartialFunction = Some(getDomainApiResourceDocs) + ).allowSystemSpace() + // Route: PUT /obp/v7.0.0/management/banks/BANK_ID/domain-apis/DOMAIN_API_ID lazy val updateDomainApi: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ PUT -> `prefixPath` / "management" / "banks" / bankIdInUrl / "domain-apis" / domainApiId => diff --git a/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala index 77a5713cdb..ebf9390d06 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala @@ -65,6 +65,7 @@ class DomainApisTest extends V600ServerSetup { object ApiEndpoint4 extends Tag("updateDomainApi") object ApiEndpoint5 extends Tag("deleteDomainApi") object ApiEndpoint6 extends Tag("getPubliclyDiscoverableDomainApis") + object ApiEndpoint7 extends Tag("getDomainApiResourceDocs") private val SYS = DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID private val owner = "domain-api-owner" @@ -301,6 +302,26 @@ class DomainApisTest extends V600ServerSetup { paths.exists(_.contains("banks")) shouldBe false makeGetRequest((under(basePath) / "openapi.yaml").GET).code should equal(200) + When("its Resource Docs are read, without logging in") + val resourceDocsAt = v7 / "banks" / SYS / "domain-apis" / "resource-docs" + val resourceDocs = makeGetRequest(resourceDocsAt.GET < basePath)) + withClue(resourceDocs.body) { resourceDocs.code should equal(200) } + Then("they are the OpenAPI document's endpoints, each at its full published path") + val docUrls = (resourceDocs.body \ "resource_docs").children.map(doc => + ((doc \ "request_url").extract[String], (doc \ "specified_url").extract[String])) + docUrls.map(_._1) should contain(s"/$basePath/$entity") + docUrls.map(_._1) should contain(s"/$basePath/$queryPath/names") + docUrls.foreach { case (requestUrl, specifiedUrl) => + requestUrl should startWith(s"/$basePath/") + specifiedUrl should equal(requestUrl) + } + And("base_path is required, and must be a Domain API of that space") + makeGetRequest(resourceDocsAt.GET).code should equal(400) + val unknown = makeGetRequest(resourceDocsAt.GET < s"unknown-$suffix/v1")) + unknown.code should equal(404) + message(unknown) should include(DomainApiNotFound) + makeGetRequest((v7 / "banks" / testBankId1.value / "domain-apis" / "resource-docs").GET < basePath)).code should equal(404) + Then("the documentation is true to the endpoints: each documented path without a placeholder is served, and the documented example has the response's fields") val route = DomainApis.domainApiProvider.vend.routes().find(_.basePath == basePath) .getOrElse(fail("the previous scenario registers this Domain API")) From 56be28ff28ddc10195e7f69a40032307a60908e8 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 13 Oct 2026 18:59:56 +0200 Subject: [PATCH 2/3] Fix to GET Products by code + Domain APIs + guards regarding hyphen (-) in Dynamic Entities --- .../dynamic/domainapi/DomainApiPaths.scala | 76 +++++-- .../dynamic/domainapi/Http4sDomainApi.scala | 11 +- .../scala/code/api/util/ErrorMessages.scala | 3 +- .../main/scala/code/api/util/Glossary.scala | 9 +- .../main/scala/code/api/util/NewStyle.scala | 29 ++- .../scala/code/api/v4_0_0/Http4s400.scala | 10 +- .../code/api/v7_0_0/Http4s700DomainApis.scala | 31 +++ .../bankconnectors/LocalMappedConnector.scala | 6 +- .../main/scala/code/domainapi/DomainApi.scala | 5 + .../domainapi/DomainApiPathRulesSpec.scala | 50 +++- .../scala/code/api/v4_0_0/ProductTest.scala | 33 +++ .../code/api/v7_0_0/DomainApisTest.scala | 213 +++++++++++++++++- release_notes.md | 4 + 13 files changed, 444 insertions(+), 36 deletions(-) diff --git a/obp-api/src/main/scala/code/api/dynamic/domainapi/DomainApiPaths.scala b/obp-api/src/main/scala/code/api/dynamic/domainapi/DomainApiPaths.scala index 32ada194e9..a1a3329fa1 100644 --- a/obp-api/src/main/scala/code/api/dynamic/domainapi/DomainApiPaths.scala +++ b/obp-api/src/main/scala/code/api/dynamic/domainapi/DomainApiPaths.scala @@ -87,6 +87,38 @@ object DomainApiPaths { */ val reservedUnderBasePath: Set[String] = Set("my", "public", "community", "openapi.json", "openapi.yaml") + /** The segments after which a Dynamic Entity URL names the entity: `my/ENTITY`, `public/ENTITY`, `community/ENTITY`. */ + private val entityAccessPrefixes: Set[String] = Set("my", "public", "community") + + /** + * This is the form in which two names are compared to see whether they would answer the same URL under + * a Domain API: a hyphen in the entity-name segment of a call stands for an underscore, so `like-this` and + * `like_this` are the same name. Case is ignored, as in the comparisons that predate the hyphen rule. + */ + def comparableName(name: String): String = name.replace('-', '_').toLowerCase + + /** + * This applies a function to the segment that names the Dynamic Entity in a path under a base path: the + * first segment, or the one after `my`, `public` or `community`. Every other segment, such as a record + * id (which may contain hyphens) or `access`, is left as it is. + */ + private def mapEntityNameSegment(rest: List[String])(change: String => String): List[String] = rest match { + case prefix :: name :: tail if entityAccessPrefixes.contains(prefix) => prefix :: change(name) :: tail + case name :: tail => change(name) :: tail + case Nil => Nil + } + + /** + * A Domain API accepts the hyphenated form of a Dynamic Entity name in its URLs, because hyphens are the + * more usual word separator in a URL while entity names use underscores. This gives the path under the + * base path with the hyphens of the entity-name segment turned into underscores, or None when that + * segment has no hyphen (the path is then already in the only form there is). + */ + def withUnderscoredEntityName(rest: List[String]): Option[List[String]] = { + val underscored = mapEntityNameSegment(rest)(_.replace('-', '_')) + if (underscored == rest) None else Some(underscored) + } + private val Segment = "[a-z0-9]([a-z0-9.-]*[a-z0-9])?" private val MajorVersionSegment = "v(0|[1-9][0-9]*)".r private val SemanticVersion = "(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)".r @@ -144,7 +176,13 @@ object DomainApiPaths { // space starts at `banks`. val segments = docRequestUrl.split("/").filter(_.nonEmpty).toList.dropWhile(_ != "banks") segments match { - case "banks" :: `space` :: kind :: rest if rest.nonEmpty && (kind == dynamicEntitiesSegment || kind == dynamicResourceDocSegment) => + // A Dynamic Entity is published under the hyphenated form of its name, so the documentation shows + // one spelling; the front door also serves the underscored one, which existing clients may use. A + // name that already has a hyphen (possible for entities created before hyphens were refused) is + // published as it is, because the front door only maps a name that is not found as written. + case "banks" :: `space` :: `dynamicEntitiesSegment` :: rest if rest.nonEmpty => + Some(mapEntityNameSegment(rest)(name => if (name.contains('-')) name else name.replace('_', '-')).mkString("/", "/", "")) + case "banks" :: `space` :: `dynamicResourceDocSegment` :: rest if rest.nonEmpty => Some(rest.mkString("/", "/", "")) case _ => None } @@ -172,7 +210,8 @@ object DomainApiPaths { * name. The rules hold in every space, with or without a Domain API, so that one can be registered over * any space at any time. A path may not start with a path variable (it would match every entity name), * with a segment a Dynamic Entity URL or the Domain API's documentation already uses, or with the name - * of one of the space's Dynamic Entities, and no other doc of the same verb may match a request it + * of one of the space's Dynamic Entities, in either spelling (a Domain API reads `like-this` as the + * entity `like_this`, see [[comparableName]]), and no other doc of the same verb may match a request it * matches. A doc is never compared with itself (the same dynamicResourceDocId). */ def resourceDocAmbiguities(doc: ResourceDocPath, entityNames: List[String], otherDocs: List[ResourceDocPath]): List[String] = @@ -183,8 +222,8 @@ object DomainApiPaths { if (isPathVariable(first)) List(s"${doc.describe} starts with the path variable $first, which would also match every Dynamic Entity name") else Nil val reserved = if (reservedUnderBasePath.contains(first)) List(s"${doc.describe} starts with $first, which a Dynamic Entity URL or a Domain API's documentation already uses") else Nil - val entities = entityNames.filter(_.equalsIgnoreCase(first)) - .map(entityName => s"${doc.describe} starts with $first, the name of the Dynamic Entity $entityName") + val entities = entityNames.filter(entityName => comparableName(entityName) == comparableName(first)) + .map(entityName => s"${doc.describe} starts with $first, which a Domain API reads as the name of the Dynamic Entity $entityName") val docs = otherDocs .filterNot(other => doc.dynamicResourceDocId.isDefined && other.dynamicResourceDocId == doc.dynamicResourceDocId) .filter(other => other.verb.equalsIgnoreCase(doc.verb) && ambiguous(other.segments, doc.segments)) @@ -194,14 +233,20 @@ object DomainApiPaths { /** * This lists why a Dynamic Entity name would be ambiguous in its space, given the space's Dynamic - * Resource Docs: a doc whose path starts with the name (compared ignoring case), or a name that is a - * segment a Dynamic Entity URL or a Domain API's documentation already uses. Empty when it is neither. + * Resource Docs and its other Dynamic Entities: a doc whose path starts with the name, or another entity + * whose name is the same, both compared by [[comparableName]] (so `like-this` and `like_this` clash), or + * a name that is a segment a Dynamic Entity URL or a Domain API's documentation already uses. Empty when + * it is none of these. `otherEntityNames` must not include the entity itself, under its old name when it + * is being renamed. */ - def entityNameAmbiguities(entityName: String, docs: List[ResourceDocPath]): List[String] = { + def entityNameAmbiguities(entityName: String, docs: List[ResourceDocPath], otherEntityNames: List[String]): List[String] = { val reserved = if (reservedUnderBasePath.contains(entityName.toLowerCase)) List(s"the Dynamic Entity name $entityName is a segment a Dynamic Entity URL or a Domain API's documentation already uses") else Nil - reserved ++ docs.filter(_.segments.headOption.exists(_.equalsIgnoreCase(entityName))) - .map(doc => s"${doc.describe} starts with $entityName, the name of the Dynamic Entity") + val clashingDocs = docs.filter(_.segments.headOption.exists(first => comparableName(first) == comparableName(entityName))) + .map(doc => s"${doc.describe} starts with ${doc.segments.head}, which a Domain API reads as the name of the Dynamic Entity $entityName") + val clashingEntities = otherEntityNames.filter(other => comparableName(other) == comparableName(entityName)) + .map(other => s"the Dynamic Entity names $entityName and $other would answer the same URL under a Domain API") + reserved ++ clashingDocs ++ clashingEntities } /** @@ -211,7 +256,9 @@ object DomainApiPaths { */ def ambiguitiesInSpace(entityNames: List[String], docs: List[ResourceDocPath]): List[String] = { val docAmbiguities = docs.zipWithIndex.flatMap { case (doc, index) => resourceDocAmbiguities(doc, entityNames, docs.drop(index + 1)) } - val entityAmbiguities = entityNames.flatMap(entityNameAmbiguities(_, Nil)) + val entityAmbiguities = entityNames.zipWithIndex.flatMap { case (entityName, index) => + entityNameAmbiguities(entityName, Nil, entityNames.drop(index + 1)) + } (docAmbiguities ++ entityAmbiguities).distinct.sorted } @@ -228,9 +275,12 @@ object DomainApiPaths { def storedResourceDocAmbiguities(space: Option[String], dynamicResourceDocId: Option[String], verb: String, path: String, name: String): List[String] = resourceDocAmbiguities(ResourceDocPath(dynamicResourceDocId, verb, path, name), entityNamesIn(space), resourceDocPathsIn(space)) - /** [[entityNameAmbiguities]] for a Dynamic Entity about to be created or renamed in a space. */ - def storedEntityNameAmbiguities(space: Option[String], entityName: String): List[String] = - entityNameAmbiguities(entityName, resourceDocPathsIn(space)) + /** + * [[entityNameAmbiguities]] for a Dynamic Entity about to be created (`currentName` None) or renamed + * (`currentName` its name now, which is left out of the comparison with the space's other entities). + */ + def storedEntityNameAmbiguities(space: Option[String], entityName: String, currentName: Option[String]): List[String] = + entityNameAmbiguities(entityName, resourceDocPathsIn(space), entityNamesIn(space).filterNot(name => currentName.contains(name))) /** [[ambiguitiesInSpace]] for a space as the database holds it now. */ def storedAmbiguitiesInSpace(space: Option[String]): List[String] = diff --git a/obp-api/src/main/scala/code/api/dynamic/domainapi/Http4sDomainApi.scala b/obp-api/src/main/scala/code/api/dynamic/domainapi/Http4sDomainApi.scala index 25531a702f..8fea433e93 100644 --- a/obp-api/src/main/scala/code/api/dynamic/domainapi/Http4sDomainApi.scala +++ b/obp-api/src/main/scala/code/api/dynamic/domainapi/Http4sDomainApi.scala @@ -56,7 +56,11 @@ import org.json4s.native.JsonMethods.compact * the OBP URL. A Domain API adds no access of its own: a call under its base path is allowed or refused * exactly as the same call to the OBP URL would be. * - * A Dynamic Resource Doc of the space is tried first, then a Dynamic Entity of the space. Every space is + * A Dynamic Resource Doc of the space is tried first, then a Dynamic Entity of the space: first under the + * entity name as written, then, when it has hyphens, under the name with those hyphens turned into + * underscores, so `technologies-practices-processes` reaches the entity `technologies_practices_processes` + * (see [[DomainApiPaths.withUnderscoredEntityName]]). An unknown entity name falls through, so the second + * try only runs when the first found nothing. Every space is * kept free of ambiguous paths (see [[DomainApiPaths.resourceDocAmbiguities]]), with or without a Domain * API, so at most one of them matches a call and the order does not change which one answers. Should an * ambiguity that predates those rules remain, the Dynamic Resource Doc wins: its path names a literal @@ -94,8 +98,11 @@ object Http4sDomainApi extends MdcLoggable { case _ => val marked = req.withAttribute(domainApiCallKey, DomainApiCall(route.domainApiId, route.basePath, req.uri.renderString)) + def entity(path: List[String]) = + Http4sDynamicEntity.wrappedRoutesDynamicEntityV700.run(withPath(marked, DomainApiPaths.dynamicEntityPath(route.bankId, path))) Http4sDynamicEndpoint.wrappedRoutesDynamicEndpoint.run(withPath(marked, DomainApiPaths.dynamicResourceDocPath(route.bankId, rest))) - .orElse(Http4sDynamicEntity.wrappedRoutesDynamicEntityV700.run(withPath(marked, DomainApiPaths.dynamicEntityPath(route.bankId, rest)))) + .orElse(entity(rest)) + .orElse(DomainApiPaths.withUnderscoredEntityName(rest).fold(OptionT.none[IO, Response[IO]])(entity)) } } } 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 10a49d5e3d..b5fc4e34c6 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -118,7 +118,8 @@ object ErrorMessages { val DomainApiNotFound = "OBP-09029: Domain API not found in this space. Please specify a valid value for DOMAIN_API_ID." val InvalidDomainApiTitle = "OBP-09030: Invalid title or description. title must be 1 to 255 characters and description at most 2000." val DomainApiPathClash = "OBP-09031: Endpoints of this space are ambiguous with each other, so a Domain API cannot publish the space until one of them is renamed or removed: " - val DynamicPathAmbiguous = "OBP-09032: This would make a path ambiguous in its space. The Dynamic Entities and Dynamic Resource Docs of a space share one set of paths, which a Domain API can publish under its base path at any time, so a Dynamic Resource Doc's path may not start with a path variable, with my, public, community, openapi.json or openapi.yaml, or with the name of one of the space's Dynamic Entities; a Dynamic Entity may not be named my, public, community, openapi.json or openapi.yaml, nor after the first segment of one of the space's Dynamic Resource Docs; and two Dynamic Resource Docs of one verb may not both match one request. Rename one of them: " + val DynamicPathAmbiguous = "OBP-09032: This would make a path ambiguous in its space. The Dynamic Entities and Dynamic Resource Docs of a space share one set of paths, which a Domain API can publish under its base path at any time, and a Domain API reads a hyphen in an entity name as an underscore, so a Dynamic Resource Doc's path may not start with a path variable, with my, public, community, openapi.json or openapi.yaml, or with the name of one of the space's Dynamic Entities in either spelling (like-this or like_this); a Dynamic Entity may not be named my, public, community, openapi.json or openapi.yaml, nor after the first segment of one of the space's Dynamic Resource Docs, nor so that it differs from another of the space's Dynamic Entities only by hyphens for underscores; and two Dynamic Resource Docs of one verb may not both match one request. Rename one of them: " + val DynamicEntityNameHasHyphenOrSpace = "OBP-09033: Invalid Dynamic Entity name. A Dynamic Entity name may not contain a hyphen or a space; use an underscore between words, e.g. 'customer_preferences'. A Domain API serves the entity at the hyphenated form of its name as well. " // General messages (OBP-10XXX) 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 c26864ba5c..9b67523603 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -4349,17 +4349,19 @@ object Glossary extends MdcLoggable { |* `/carbon-registry/v1/registry/summary` answers what the Dynamic Resource Doc at `/obp/dynamic-endpoint/banks/SYS/dynamic-resource-doc/registry/summary` answers; |* `/carbon-registry/v1/openapi.yaml` and `/carbon-registry/v1/openapi.json` are its OpenAPI document, with the Domain API's own title, description, version and server, and only its own endpoints. | -|Dynamic Entity names are not renamed: only the part of the URL before them is. The endpoints that create and change definitions stay at their OBP URLs; a Domain API publishes the endpoints that serve and take data. Dynamic Endpoints made from a Swagger file are not published under a Domain API yet. +|**Hyphens in entity names.** A Dynamic Entity name uses underscores, such as `technologies_practices_processes`, and a hyphen is the more usual word separator in a URL, so under a base path the hyphenated form of an entity name answers too: `/carbon-registry/v1/public/technologies-practices-processes` answers what `/carbon-registry/v1/public/technologies_practices_processes` answers. Only the segment that names the entity is read this way: the first one after the base path, or the one after `my`, `public` or `community`. Record ids (which may contain hyphens), `access` and the paths of Dynamic Resource Docs (such as `/registry/activities-query`) are passed on as written, and a Dynamic Resource Doc is still tried first. The OpenAPI document lists each entity path once, in the hyphenated form. Request and response JSON keys, Role names and the OBP URLs keep the underscored name, and API Metrics record the OBP URL the call was rewritten to. A new Dynamic Entity name may not contain a hyphen or a space in any API version (${ErrorMessages.DynamicEntityNameHasHyphenOrSpace.takeWhile(_ != ':')}), so a hyphen in a URL can only stand for an underscore. +| +|Apart from that, Dynamic Entity names are not renamed: only the part of the URL before them is. The endpoints that create and change definitions stay at their OBP URLs; a Domain API publishes the endpoints that serve and take data. Dynamic Endpoints made from a Swagger file are not published under a Domain API yet. | |**It only renames.** A call under a base path is rewritten to the OBP URL and runs exactly as a call to that URL would: the same authentication, Roles, Consents, rate limits, row-level access and field restrictions, and the same API Metrics. A Domain API gives no access the OBP URL would not give. The one difference in a response is that a Dynamic Entity record response leaves out `bank_id`, because the base path already fixes the space; the OpenAPI document's examples leave it out too. | |**Base path and version.** The base path is two to five segments of lowercase letters, digits, hyphens and dots, ending with the major version as `vN`, for example `carbon-registry/v1`. It may not start with a segment OBP serves itself (such as `obp` or `open-banking`), and may not overlap another Domain API's base path. The Domain API's `version` is its full semantic version, MAJOR.MINOR.PATCH, whose MAJOR is the N of the base path. A compatible change, such as a new optional field or a new Dynamic Query, edits `version` (or leaves it) and keeps every URL; a breaking change gets a new Domain API with a new base path, for example `carbon-registry/v2`, which can run alongside the old one while clients move. OBP never changes the version itself. For Dynamic Entities OBP already keeps changes compatible once an entity holds records (only optional properties may be added); a Dynamic Resource Doc can be changed in any way, so keeping its changes compatible is up to its author. | -|**No ambiguous paths.** Under a base path the Dynamic Entities and Dynamic Resource Docs of the space share one set of paths, and a Dynamic Entity owns every path that starts with its name. Because a Domain API may be registered over any space at any time, OBP keeps every space unambiguous, with or without a Domain API: creating or changing a Dynamic Resource Doc is refused (${ErrorMessages.DynamicPathAmbiguous.takeWhile(_ != ':')}) when its path would start with a path variable, with `my`, `public`, `community`, `openapi.json` or `openapi.yaml`, or with the name of one of the space's Dynamic Entities, or when another doc of the same verb would match a request it matches (`/registry/REGISTRY_ID` and `/registry/summary` both match `/registry/summary`). Creating or renaming a Dynamic Entity is refused when one of the space's Dynamic Resource Docs starts with its name, or when it would be named one of those reserved segments. A space holding an ambiguity from before these rules can't have a Domain API (${ErrorMessages.DomainApiPathClash.takeWhile(_ != ':')}) until it is resolved. Under a base path a Dynamic Resource Doc is tried first, then a Dynamic Entity; with the rules above, at most one of them can answer a request. +|**No ambiguous paths.** Under a base path the Dynamic Entities and Dynamic Resource Docs of the space share one set of paths, and a Dynamic Entity owns every path that starts with its name. Because a Domain API may be registered over any space at any time, OBP keeps every space unambiguous, with or without a Domain API: creating or changing a Dynamic Resource Doc is refused (${ErrorMessages.DynamicPathAmbiguous.takeWhile(_ != ':')}) when its path would start with a path variable, with `my`, `public`, `community`, `openapi.json` or `openapi.yaml`, or with the name of one of the space's Dynamic Entities in either spelling (a doc at `/like-this/summary` is refused while the entity `like_this` exists), or when another doc of the same verb would match a request it matches (`/registry/REGISTRY_ID` and `/registry/summary` both match `/registry/summary`). Creating or renaming a Dynamic Entity is refused when one of the space's Dynamic Resource Docs starts with its name in either spelling, when another of the space's Dynamic Entities has the same name once hyphens are read as underscores (possible only with a name from before hyphens were refused), or when it would be named one of those reserved segments. A space holding an ambiguity from before these rules can't have a Domain API (${ErrorMessages.DomainApiPathClash.takeWhile(_ != ':')}) until it is resolved. Under a base path a Dynamic Resource Doc is tried first, then a Dynamic Entity; with the rules above, at most one of them can answer a request. | |**Managing.** `/obp/v7.0.0/management/banks/BANK_ID/domain-apis` creates and lists a space's Domain APIs, and `/obp/v7.0.0/management/banks/BANK_ID/domain-apis/DOMAIN_API_ID` reads, updates and deletes one. BANK_ID is a bank's id or `SYS`. The Roles are CanCreateDomainApi, CanGetDomainApis, CanUpdateDomainApi and CanDeleteDomainApi, held at that BANK_ID. | -|**Discovery.** A Domain API whose `is_publicly_discoverable` is true is listed, without logging in, at `/obp/v7.0.0/banks/BANK_ID/domain-apis`, with its base path, version, title, description, URL and OpenAPI document. It is false unless set, so a Domain API is only listed publicly on purpose. It only controls that listing: the calls under the base path and the OpenAPI document are served the same either way. +|**Discovery.** A Domain API whose `is_publicly_discoverable` is true is listed, without logging in, at `/obp/v7.0.0/banks/BANK_ID/domain-apis`, with its base path, version, title, description, URL and OpenAPI document. `/obp/v7.0.0/domain-apis` lists them for every space at once. It is false unless set, so a Domain API is only listed publicly on purpose. It only controls that listing: the calls under the base path and the OpenAPI document are served the same either way. | |**Resource Docs.** `/obp/v7.0.0/banks/BANK_ID/domain-apis/resource-docs?base_path=BASE_PATH` gives the same endpoints as the OpenAPI document as OBP Resource Docs, each at its full published path, for API Explorer and other OBP tools. No login is needed. | @@ -7234,6 +7236,7 @@ object Glossary extends MdcLoggable { | |- **Trace history** lists the commits that changed the lines the finding names, and Claude says which commit introduced the problem (at most $$0.30). |- **Look again: still an issue?** has Claude read the code the instance runs now, the commits since the finding was made, and whether its log messages are still seen. It answers in one line: still an issue, fixed (and by which commit), or could not tell. It takes a few minutes and costs at most $$0.50. While it works, the page shows how long it has been going and the command it is running. It does not change the finding's status. + |- **Look again at the security findings to review**, above the list, does the same for each security finding in To review, one at a time. Those Claude finds fixed are marked fixed and move to Done, with Claude's answer as the comment. | |Then answer, with a comment for the analyst if you like: | 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 9603c8aec8..b057402e0a 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -3553,6 +3553,20 @@ object NewStyle extends MdcLoggable{ Constant.incrementCacheNamespaceVersion(Constant.RD_ALL_NAMESPACE) } + /** + * This says whether a new Dynamic Entity name is allowed in every API version: it may not contain a + * hyphen or any whitespace. A Domain API reads a hyphen in an entity-name segment of its URLs as an + * underscore, so a name with a hyphen could be confused with the name that has an underscore there. + * v6.0.0 and later also require lowercase snake_case; v4.0.0 still accepts other letters and digits. + * Entities created before this rule keep their names, and only a new or changed name is checked. + */ + private def dynamicEntityNameIsAllowed(entityName: String): Boolean = + !entityName.exists(character => character == '-' || character.isWhitespace) + + private def refuseDynamicEntityName(entityName: String, callContext: Option[CallContext]): Future[Box[DynamicEntityT]] = + Helper.booleanToFuture(s"$DynamicEntityNameHasHyphenOrSpace Current value: '$entityName'", 400, cc=callContext)(false) + .map(_.asInstanceOf[Box[DynamicEntityT]]) + private def createDynamicEntity(dynamicEntity: DynamicEntityT, callContext: Option[CallContext]): Future[Box[DynamicEntityT]] = { val existsDynamicEntity = DynamicEntityProvider.connectorMethodProvider.vend.getByEntityName(dynamicEntity.bankId, dynamicEntity.entityName) @@ -3565,7 +3579,11 @@ object NewStyle extends MdcLoggable{ return Helper.booleanToFuture(errorMsg, cc=callContext)(existsDynamicEntity.isEmpty).map(_.asInstanceOf[Box[DynamicEntityT]]) } - val ambiguities = DomainApiPaths.storedEntityNameAmbiguities(dynamicEntity.bankId, dynamicEntity.entityName) + if (!dynamicEntityNameIsAllowed(dynamicEntity.entityName)) { + return refuseDynamicEntityName(dynamicEntity.entityName, callContext) + } + + val ambiguities = DomainApiPaths.storedEntityNameAmbiguities(dynamicEntity.bankId, dynamicEntity.entityName, None) if (ambiguities.nonEmpty) { return Helper.booleanToFuture(s"$DynamicPathAmbiguous${ambiguities.mkString("; ")}", 409, cc=callContext)(false).map(_.asInstanceOf[Box[DynamicEntityT]]) } @@ -3600,9 +3618,12 @@ object NewStyle extends MdcLoggable{ return Helper.booleanToFuture(errorMsg, cc=callContext)(existsDynamicEntity.isEmpty).map(_.asInstanceOf[Box[DynamicEntityT]]) } - // Only a new name is checked, so an entity caught in an ambiguity that predates the rule can - // still have its definition changed. - val ambiguities = DomainApiPaths.storedEntityNameAmbiguities(dynamicEntity.bankId, dynamicEntity.entityName) + // Only a new name is checked, so an entity caught in an ambiguity that predates the rule, or named + // with a hyphen before hyphens were refused, can still have its definition changed. + if (!dynamicEntityNameIsAllowed(dynamicEntity.entityName)) { + return refuseDynamicEntityName(dynamicEntity.entityName, callContext) + } + val ambiguities = DomainApiPaths.storedEntityNameAmbiguities(dynamicEntity.bankId, dynamicEntity.entityName, Some(originEntityName)) if (ambiguities.nonEmpty) { return Helper.booleanToFuture(s"$DynamicPathAmbiguous${ambiguities.mkString("; ")}", 409, cc=callContext)(false).map(_.asInstanceOf[Box[DynamicEntityT]]) } diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index c497bf43cd..4b65edd1db 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -1763,7 +1763,7 @@ object Http4s400 { |${userAuthenticationMessage(true)}""", dynamicEntityRequestBodyExample.copy(bankId = None), dynamicEntityResponseBodyExample, - List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, DynamicPathAmbiguous, UnknownError), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, DynamicEntityNameHasHyphenOrSpace, DynamicPathAmbiguous, UnknownError), List(apiTagManageDynamicEntity, apiTagApi), Some(List(canCreateDynamicEntityDefinition)), http4sPartialFunction = Some(createSystemDynamicEntity)) @@ -1800,7 +1800,7 @@ object Http4s400 { |${userAuthenticationMessage(true)}""", dynamicEntityRequestBodyExample.copy(bankId = None), dynamicEntityResponseBodyExample, - List(BankNotFound, AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, DynamicPathAmbiguous, UnknownError), + List(BankNotFound, AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, DynamicEntityNameHasHyphenOrSpace, DynamicPathAmbiguous, UnknownError), List(apiTagManageDynamicEntity, apiTagApi), Some(List(canCreateDynamicEntityDefinition)), http4sPartialFunction = Some(createBankLevelDynamicEntity)) @@ -1837,7 +1837,7 @@ object Http4s400 { |${userAuthenticationMessage(true)}""", dynamicEntityRequestBodyExample.copy(bankId = None), dynamicEntityResponseBodyExample, - List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, DynamicEntityUpdateNotSchemaCompatible, DynamicPathAmbiguous, UnknownError), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, DynamicEntityNameHasHyphenOrSpace, DynamicEntityUpdateNotSchemaCompatible, DynamicPathAmbiguous, UnknownError), List(apiTagManageDynamicEntity, apiTagApi), Some(List(canUpdateDynamicEntityDefinition)), http4sPartialFunction = Some(updateSystemDynamicEntity)) @@ -1874,7 +1874,7 @@ object Http4s400 { |${userAuthenticationMessage(true)}""", dynamicEntityRequestBodyExample.copy(bankId = None), dynamicEntityResponseBodyExample, - List(BankNotFound, AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, DynamicEntityUpdateNotSchemaCompatible, DynamicPathAmbiguous, UnknownError), + List(BankNotFound, AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, DynamicEntityNameHasHyphenOrSpace, DynamicEntityUpdateNotSchemaCompatible, DynamicPathAmbiguous, UnknownError), List(apiTagManageDynamicEntity, apiTagApi), Some(List(canUpdateDynamicEntityDefinition)), http4sPartialFunction = Some(updateBankLevelDynamicEntity)) @@ -2002,7 +2002,7 @@ object Http4s400 { |${userAuthenticationMessage(true)}""", dynamicEntityRequestBodyExample.copy(bankId = None), dynamicEntityResponseBodyExample, - List(AuthenticatedUserIsRequired, InvalidMyDynamicEntityUser, InvalidJsonFormat, DynamicEntityUpdateNotSchemaCompatible, DynamicPathAmbiguous, UnknownError), + List(AuthenticatedUserIsRequired, InvalidMyDynamicEntityUser, InvalidJsonFormat, DynamicEntityNameHasHyphenOrSpace, DynamicEntityUpdateNotSchemaCompatible, DynamicPathAmbiguous, UnknownError), List(apiTagManageDynamicEntity, apiTagApi), None, http4sPartialFunction = Some(updateMyDynamicEntity)) diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DomainApis.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DomainApis.scala index ed9acf01c4..aedca37b22 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DomainApis.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DomainApis.scala @@ -279,6 +279,37 @@ object Http4s700DomainApis { http4sPartialFunction = Some(getPubliclyDiscoverableDomainApis) ).allowSystemSpace() + // Route: GET /obp/v7.0.0/domain-apis (public) + lazy val getPubliclyDiscoverableDomainApisInEverySpace: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "domain-apis" => + EndpointHelpers.executeAndRespond(req) { cc => + Future(provider.getPubliclyDiscoverableInEverySpace()).map(box => + PublicDomainApisJsonV700(fullOrFail(box, cc).map(JSONFactory700DomainApis.createPublicDomainApiJson))) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getPubliclyDiscoverableDomainApisInEverySpace), + "GET", + "/domain-apis", + "Get Publicly Discoverable Domain APIs in Every Space", + s"""The publicly discoverable Domain APIs (`is_publicly_discoverable`) of every space on this instance, each + |with its space (`bank_id`), base path, version, title, description, `url` and `openapi_url`. For a tool + |such as API Explorer that offers every published API without knowing the spaces in advance; + |`GET /obp/v7.0.0/banks/BANK_ID/domain-apis` gives one space's. + | + |No Authentication is Required. + | + |For more information see ${Glossary.getGlossaryItemLink("Domain APIs")}""".stripMargin, + EmptyBody, + JSONFactory700DomainApis.publicDomainApisJsonV700Example, + List(UnknownError), + apiTagDynamic :: apiTagApi :: Nil, + None, + http4sPartialFunction = Some(getPubliclyDiscoverableDomainApisInEverySpace) + ) + // Route: GET /obp/v7.0.0/banks/BANK_ID/domain-apis/resource-docs?base_path=BASE_PATH (public) lazy val getDomainApiResourceDocs: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / bankIdInUrl / "domain-apis" / "resource-docs" => diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index e63d3d9076..1e45fcd475 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -2708,7 +2708,11 @@ object LocalMappedConnector extends Connector with MdcLoggable { case Some(tagSet) => codesFromAttrs.filter(tagSet.contains) case None => codesFromAttrs } - MappedProduct.findAll(ByList(MappedProduct.mCode, finalCodes)) + // Product codes are unique only within a bank, so the bank has to be named here as well. + MappedProduct.findAll( + By(MappedProduct.mBankId, bankId.value), + ByList(MappedProduct.mCode, finalCodes) + ) } } }}.map(products => (products, callContext)) diff --git a/obp-api/src/main/scala/code/domainapi/DomainApi.scala b/obp-api/src/main/scala/code/domainapi/DomainApi.scala index b815b744d1..c306ebe9e4 100644 --- a/obp-api/src/main/scala/code/domainapi/DomainApi.scala +++ b/obp-api/src/main/scala/code/domainapi/DomainApi.scala @@ -79,6 +79,8 @@ trait DomainApiProvider { def getAll(bankId: String): Box[List[DomainApiTrait]] /** The Domain APIs of a space that are publicly discoverable. */ def getPubliclyDiscoverable(bankId: String): Box[List[DomainApiTrait]] + /** The publicly discoverable Domain APIs of every space. */ + def getPubliclyDiscoverableInEverySpace(): Box[List[DomainApiTrait]] /** Every Domain API of every space, read from the table (not the request path's cached list). */ def getAllInEverySpace(): Box[List[DomainApiTrait]] def update(bankId: String, domainApiId: String, basePath: String, version: String, title: String, @@ -144,6 +146,9 @@ object DomainApiDbProvider extends DomainApiProvider { override def getPubliclyDiscoverable(bankId: String): Box[List[DomainApiTrait]] = tryo(DomainApi.findAll(By(DomainApi.BankId, bankId), By(DomainApi.IsPubliclyDiscoverable, true), OrderBy(DomainApi.BasePath, Ascending))) + override def getPubliclyDiscoverableInEverySpace(): Box[List[DomainApiTrait]] = + tryo(DomainApi.findAll(By(DomainApi.IsPubliclyDiscoverable, true), OrderBy(DomainApi.BasePath, Ascending))) + override def getAllInEverySpace(): Box[List[DomainApiTrait]] = tryo(DomainApi.findAll(OrderBy(DomainApi.BasePath, Ascending))) diff --git a/obp-api/src/test/scala/code/api/dynamic/domainapi/DomainApiPathRulesSpec.scala b/obp-api/src/test/scala/code/api/dynamic/domainapi/DomainApiPathRulesSpec.scala index 3a6508a8b6..900d1514d2 100644 --- a/obp-api/src/test/scala/code/api/dynamic/domainapi/DomainApiPathRulesSpec.scala +++ b/obp-api/src/test/scala/code/api/dynamic/domainapi/DomainApiPathRulesSpec.scala @@ -56,6 +56,12 @@ class DomainApiPathRulesSpec extends FlatSpec with Matchers { DomainApiPaths.resourceDocAmbiguities(doc("/Customers/summary"), List("customers"), Nil) should have size 1 } + it should "refuse a path that starts with the hyphenated form of a Dynamic Entity's name, and the reverse" in { + DomainApiPaths.resourceDocAmbiguities(doc("/like-this/summary"), List("like_this"), Nil) should have size 1 + DomainApiPaths.resourceDocAmbiguities(doc("/like_this/summary"), List("like-this"), Nil) should have size 1 + DomainApiPaths.resourceDocAmbiguities(doc("/like-that/summary"), List("like_this"), Nil) shouldBe empty + } + it should "refuse a path that starts with a path variable, a reserved segment, or has no segment" in { DomainApiPaths.resourceDocAmbiguities(doc("/ITEM_ID/summary"), Nil, Nil) should have size 1 List("my", "public", "community", "openapi.json", "openapi.yaml").foreach { reserved => @@ -76,9 +82,43 @@ class DomainApiPathRulesSpec extends FlatSpec with Matchers { } "entityNameAmbiguities" should "refuse a name that a doc's path starts with, or a reserved segment" in { - DomainApiPaths.entityNameAmbiguities("customers", List(doc("/customers/summary"))) should have size 1 - DomainApiPaths.entityNameAmbiguities("customers", List(doc("/registry/customers"))) shouldBe empty - DomainApiPaths.entityNameAmbiguities("public", Nil) should have size 1 + DomainApiPaths.entityNameAmbiguities("customers", List(doc("/customers/summary")), Nil) should have size 1 + DomainApiPaths.entityNameAmbiguities("customers", List(doc("/registry/customers")), Nil) shouldBe empty + DomainApiPaths.entityNameAmbiguities("public", Nil, Nil) should have size 1 + } + + it should "refuse a name that a doc's path starts with in its hyphenated form" in { + DomainApiPaths.entityNameAmbiguities("like_this", List(doc("/like-this/summary")), Nil) should have size 1 + } + + it should "refuse a name another entity has once hyphens are read as underscores, and only then" in { + DomainApiPaths.entityNameAmbiguities("like_this", Nil, List("like-this")) should have size 1 + DomainApiPaths.entityNameAmbiguities("like_this", Nil, List("like_that", "other")) shouldBe empty + } + + "withUnderscoredEntityName" should "turn the hyphens of the entity-name segment into underscores and leave every other segment alone" in { + val recordId = "6f1c2a3e-1111-2222-3333-444455556666" + DomainApiPaths.withUnderscoredEntityName(List("like-this")) shouldBe Some(List("like_this")) + DomainApiPaths.withUnderscoredEntityName(List("like-this", recordId, "access")) shouldBe Some(List("like_this", recordId, "access")) + List("my", "public", "community").foreach { prefix => + DomainApiPaths.withUnderscoredEntityName(List(prefix, "like-this", recordId)) shouldBe Some(List(prefix, "like_this", recordId)) + } + } + + it should "give None when the entity-name segment has no hyphen" in { + DomainApiPaths.withUnderscoredEntityName(List("like_this", "6f1c2a3e-1111")) shouldBe None + DomainApiPaths.withUnderscoredEntityName(List("public", "like_this")) shouldBe None + DomainApiPaths.withUnderscoredEntityName(Nil) shouldBe None + } + + "publishedPath" should "publish a Dynamic Entity under the hyphenated form of its name, and a Dynamic Resource Doc as it is" in { + DomainApiPaths.publishedPath("SYS", "/obp/v7.0.0/banks/SYS/dynamic-entities/like_this/LIKE_THIS_ID") shouldBe Some("/like-this/LIKE_THIS_ID") + DomainApiPaths.publishedPath("SYS", "/banks/SYS/dynamic-entities/public/like_this") shouldBe Some("/public/like-this") + DomainApiPaths.publishedPath("SYS", "/banks/SYS/dynamic-resource-doc/registry_summary/SITE_ID") shouldBe Some("/registry_summary/SITE_ID") + } + + it should "publish a name that already has a hyphen as it is, since only a name not found as written is mapped" in { + DomainApiPaths.publishedPath("SYS", "/banks/SYS/dynamic-entities/old-style_name") shouldBe Some("/old-style_name") } "ambiguitiesInSpace" should "list each ambiguity in a space once" in { @@ -87,4 +127,8 @@ class DomainApiPathRulesSpec extends FlatSpec with Matchers { ambiguities should have size 2 DomainApiPaths.ambiguitiesInSpace(List("customers"), List(doc("/registry/summary"))) shouldBe empty } + + it should "list two entities whose names differ only by hyphens for underscores" in { + DomainApiPaths.ambiguitiesInSpace(List("like-this", "like_this", "other"), Nil) should have size 1 + } } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/ProductTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/ProductTest.scala index 4070377a98..577010ccdb 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/ProductTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/ProductTest.scala @@ -150,6 +150,39 @@ class ProductTest extends V400ServerSetup { products.products.head.product_code should be ("PRODUCT_CODE") } + + scenario("Filtering getProducts by attribute returns only this bank's products, even when another bank uses the same product code", ApiEndpoint3, VersionOfApi) { + val sharedCode = "SHARED_" + java.util.UUID.randomUUID().toString.take(8) + val otherBankId = "other-bank-" + java.util.UUID.randomUUID().toString.take(8) + val attributeName = "filter_test_" + java.util.UUID.randomUUID().toString.take(8) + + Given("A product with the same code at this bank and at another bank") + List(testBankId, otherBankId).foreach { bankId => + code.products.MappedProduct.create + .mBankId(bankId) + .mCode(sharedCode) + .mName(s"Product $sharedCode at $bankId") + .saveMe() + } + + And("Only this bank's product has the attribute being filtered on") + code.productAttributeattribute.MappedProductAttribute.create + .mBankId(testBankId) + .mCode(sharedCode) + .mName(attributeName) + .mType("STRING") + .mValue("yes") + .saveMe() + + When("We get this bank's products filtered by that attribute") + val request400 = (v4_0_0_Request / "banks" / testBankId / "products").GET <@(user1) < "yes") + val response400 = makeGetRequest(request400) + + Then("We should get a 200 with this bank's product only") + response400.code should equal(200) + val products = response400.body.extract[ProductsJsonV400].products + products.map(product => (product.bank_id, product.product_code)) should equal(List((testBankId, sharedCode))) + } } diff --git a/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala index ebf9390d06..32d25d9654 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala @@ -66,11 +66,14 @@ class DomainApisTest extends V600ServerSetup { object ApiEndpoint5 extends Tag("deleteDomainApi") object ApiEndpoint6 extends Tag("getPubliclyDiscoverableDomainApis") object ApiEndpoint7 extends Tag("getDomainApiResourceDocs") + object ApiEndpoint8 extends Tag("getPubliclyDiscoverableDomainApisInEverySpace") private val SYS = DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID private val owner = "domain-api-owner" private val suffix = java.util.UUID.randomUUID().toString.replace("-", "").take(8) private val entity = s"activity_$suffix" + /** The form in which the Domain API publishes an entity name: with hyphens for underscores. */ + private def hyphenated(entityName: String) = entityName.replace('_', '-') private val queryPath = s"summary_$suffix" private def v7 = baseRequest / "obp" / "v7.0.0" @@ -172,13 +175,18 @@ class DomainApisTest extends V600ServerSetup { } scenario("a Domain API is listed publicly only once it is made publicly discoverable", - ApiEndpoint1, ApiEndpoint4, ApiEndpoint6, VersionOfApi) { + ApiEndpoint1, ApiEndpoint4, ApiEndpoint6, ApiEndpoint8, VersionOfApi) { grantAllAt(SYS) val basePath = s"discoverable-$suffix/v1" + // Listed for its space, and for every space at once: the two must agree. def publiclyListed: List[String] = { val listed = makeGetRequest(publicDomainApis(SYS).GET) withClue(listed.body) { listed.code should equal(200) } - valuesOf(listed.body \ "domain_apis", "base_path") + val inEverySpace = makeGetRequest((v7 / "domain-apis").GET) + withClue(inEverySpace.body) { inEverySpace.code should equal(200) } + val inSpace = valuesOf(listed.body \ "domain_apis", "base_path") + valuesOf(inEverySpace.body \ "domain_apis", "base_path").contains(basePath) should equal(inSpace.contains(basePath)) + inSpace } When("one is created without saying") @@ -296,7 +304,8 @@ class DomainApisTest extends V600ServerSetup { case other => fail(s"no server url: $other") } val paths = (document.body \ "paths") match { case JObject(fields) => fields.map(_._1); case _ => Nil } - paths should contain(s"/$entity") + paths should contain(s"/${hyphenated(entity)}") + paths should not contain (s"/$entity") paths should contain(s"/$queryPath/names") paths.exists(_.contains("dynamic-entities")) shouldBe false paths.exists(_.contains("banks")) shouldBe false @@ -309,7 +318,7 @@ class DomainApisTest extends V600ServerSetup { Then("they are the OpenAPI document's endpoints, each at its full published path") val docUrls = (resourceDocs.body \ "resource_docs").children.map(doc => ((doc \ "request_url").extract[String], (doc \ "specified_url").extract[String])) - docUrls.map(_._1) should contain(s"/$basePath/$entity") + docUrls.map(_._1) should contain(s"/$basePath/${hyphenated(entity)}") docUrls.map(_._1) should contain(s"/$basePath/$queryPath/names") docUrls.foreach { case (requestUrl, specifiedUrl) => requestUrl should startWith(s"/$basePath/") @@ -342,6 +351,148 @@ class DomainApisTest extends V600ServerSetup { } } + scenario("the hyphenated form of a Dynamic Entity name answers what the underscored name answers") { + val name = s"tech_practices_$suffix" + val hyphenName = hyphenated(name) + Given("an entity at SYS with personal, public and community access, holding one record whose id has hyphens") + DynamicEntityProvider.connectorMethodProvider.vend.createOrUpdate(DynamicEntityCommons( + name, s"""{"$name":{"properties":{"name":{"type":"string","example":"no-till farming"}}}}""", + None, owner, None, hasPersonalEntity = true, hasPublicAccess = true, hasCommunityAccess = true)).openOrThrowException("definition") + val recordId = java.util.UUID.randomUUID().toString + DynamicDataProvider.connectorMethodProvider.vend.save(None, name, + JObject(JField(s"${name}_id", JString(recordId)), JField("name", JString("no-till farming"))), Some(owner), false).openOrThrowException("record") + Entitlement.entitlement.vend.addEntitlement(SYS, resourceUser1.userId, s"CanGetDynamicEntityRecord_$name") + And("a Dynamic Query whose own path has a hyphen") + val queryWithHyphen = s"registry-$suffix" + queryDoc(None, s"/$queryWithHyphen/activities-query", name, s"activitiesQuery$suffix") + grantAllAt(SYS) + val basePath = s"hyphens-$suffix/v1" + val created = makePostRequest(domainApis(SYS).POST <@ (user1), registration(basePath, "1.0.0")) + withClue(created.body) { created.code should equal(201) } + + def both(segments: String*)(call: code.setup.APIResponse => Unit = _ => ()): Unit = { + def path(entityName: String) = segments.map(segment => if (segment == name) entityName else segment) + .foldLeft(under(basePath))(_ / _).GET <@ (user1) + val underscored = makeGetRequest(path(name)) + val withHyphens = makeGetRequest(path(hyphenName)) + withClue(s"${segments.mkString("/")}: ${withHyphens.body}") { + withHyphens.code should not equal (404) + withHyphens.code should equal(underscored.code) + withHyphens.body should equal(underscored.body) + } + call(withHyphens) + } + + Then("the list, a record by its id, my, public, community and a record's access answer the same in both spellings") + both(name) { response => + response.code should equal(200) + valuesOf(response.body, "name") should contain("no-till farming") + } + both(name, recordId) { response => + response.code should equal(200) + valuesOf(response.body, s"${name}_id") should contain(recordId) + } + both("my", name)() + both("public", name) { _.code should equal(200) } + both("community", name) { _.code should equal(200) } + both(name, recordId, "access")() + + And("a Dynamic Query whose path has hyphens still answers as the doc, not as an entity") + val query = makeGetRequest((under(basePath) / queryWithHyphen / "activities-query").GET <@ (user1)) + withClue(query.body) { query.code should equal(200) } + valuesOf(query.body \ "names", "name") should contain("no-till farming") + + And("the OpenAPI document lists each of the entity's paths once, with hyphens") + val document = makeGetRequest((under(basePath) / "openapi.json").GET) + withClue(document.body) { document.code should equal(200) } + val paths = (document.body \ "paths") match { case JObject(fields) => fields.map(_._1); case _ => Nil } + List(s"/$hyphenName", s"/public/$hyphenName", s"/community/$hyphenName", s"/my/$hyphenName").foreach { published => + paths should contain(published) + } + paths.exists(_.contains(name)) shouldBe false + paths should contain(s"/$queryWithHyphen/activities-query") + } + + scenario("every Dynamic Entity record operation works under a base path, in both spellings, at SYS and at a bank") { + for (space <- List(SYS, testBankId1.value)) { + val name = s"crud_${space.filter(_.isLetterOrDigit).take(6).toLowerCase}_$suffix" + Given(s"an entity $name at $space with personal, public and community access, and a Domain API over $space") + grantAt(space, canCreateDynamicEntityDefinition) + val definition = compact(render(("entity_name" -> name) ~ ("has_personal_entity" -> true) ~ + ("has_public_access" -> true) ~ ("has_community_access" -> true) ~ + ("schema" -> (("description" -> "Records written under a Domain API.") ~ ("required" -> List("name")) ~ + ("properties" -> ("name" -> (("type" -> "string") ~ ("example" -> "a name")))))))) + val defined = makePostRequest((v7 / "management" / "banks" / space / "dynamic-entities").POST <@ (user1), definition) + withClue(defined.body) { defined.code should equal(201) } + grantAllAt(space) + val basePath = s"crud-${space.filter(_.isLetterOrDigit).take(6).toLowerCase}-$suffix/v1" + val registered = makePostRequest(domainApis(space).POST <@ (user1), registration(basePath, "1.0.0")) + withClue(registered.body) { registered.code should equal(201) } + + def at(segments: String*) = segments.foldLeft(under(basePath))(_ / _) + def idOf(response: code.setup.APIResponse) = (response.body \ s"${name}_id") match { + case JString(id) => id + case other => fail(s"no ${name}_id in ${response.body}: $other") + } + def nameOf(response: code.setup.APIResponse) = (response.body \ "name") match { + case JString(value) => value + case other => fail(s"no name in ${response.body}: $other") + } + + for (spelling <- List(name, hyphenated(name))) { + When(s"a record is created, read, listed, replaced, patched and deleted at /$basePath/$spelling") + val created = makePostRequest(at(spelling).POST <@ (user1), write(("name" -> "first"): JValue)) + withClue(s"POST $spelling: ${created.body}") { created.code should equal(201) } + val recordId = idOf(created) + + val one = makeGetRequest(at(spelling, recordId).GET <@ (user1)) + withClue(s"GET $spelling/id: ${one.body}") { one.code should equal(200) } + nameOf(one) should equal("first") + + val listed = makeGetRequest(at(spelling).GET <@ (user1)) + withClue(s"GET $spelling: ${listed.body}") { listed.code should equal(200) } + valuesOf(listed.body, s"${name}_id") should contain(recordId) + + val replaced = makePutRequest(at(spelling, recordId).PUT <@ (user1), write(("name" -> "second"): JValue)) + withClue(s"PUT $spelling/id: ${replaced.body}") { replaced.code should equal(200) } + nameOf(replaced) should equal("second") + + val patched = makePatchRequest(at(spelling, recordId).PATCH <@ (user1), write(("name" -> "third"): JValue)) + withClue(s"PATCH $spelling/id: ${patched.body}") { patched.code should equal(200) } + nameOf(patched) should equal("third") + + Then("the OBP URL sees the same record") + val obp = makeGetRequest((v7 / "banks" / space / "dynamic-entities" / name / recordId).GET <@ (user1)) + withClue(obp.body) { obp.code should equal(200) } + nameOf(obp) should equal("third") + + When(s"the personal form /my/$spelling is used") + val mine = makePostRequest(at("my", spelling).POST <@ (user1), write(("name" -> "mine"): JValue)) + withClue(s"POST my/$spelling: ${mine.body}") { mine.code should equal(201) } + val myId = idOf(mine) + val myList = makeGetRequest(at("my", spelling).GET <@ (user1)) + withClue(s"GET my/$spelling: ${myList.body}") { myList.code should equal(200) } + valuesOf(myList.body, s"${name}_id") should contain(myId) + val myPut = makePutRequest(at("my", spelling, myId).PUT <@ (user1), write(("name" -> "mine again"): JValue)) + withClue(s"PUT my/$spelling/id: ${myPut.body}") { myPut.code should equal(200) } + val myDeleted = makeDeleteRequest(at("my", spelling, myId).DELETE <@ (user1)) + withClue(s"DELETE my/$spelling/id: ${myDeleted.body}") { myDeleted.code should equal(200) } + + And("the public and community forms list the record") + val public = makeGetRequest(at("public", spelling).GET) + withClue(s"GET public/$spelling: ${public.body}") { public.code should equal(200) } + valuesOf(public.body, s"${name}_id") should contain(recordId) + val community = makeGetRequest(at("community", spelling).GET <@ (user1)) + withClue(s"GET community/$spelling: ${community.body}") { community.code should equal(200) } + + Then("the record is deleted, and is gone") + val deleted = makeDeleteRequest(at(spelling, recordId).DELETE <@ (user1)) + withClue(s"DELETE $spelling/id: ${deleted.body}") { deleted.code should equal(200) } + makeGetRequest(at(spelling, recordId).GET <@ (user1)).code should equal(404) + } + } + } + scenario("a Domain API is refused while two endpoints of its space would answer the same path") { val bankId = testBankId2.value val clashing = s"clash_$suffix" @@ -426,6 +577,60 @@ class DomainApisTest extends V600ServerSetup { message(refused) should include(s"/$taken/names") } + scenario("the ambiguity rules read a hyphen in a path's first segment as an underscore") { + val bankId = testBankId1.value + val name = s"like_this_$suffix" + entityWithRecord(Some(bankId), name, "like this") + grantAt(bankId, canCreateBankLevelDynamicResourceDoc) + grantAt(bankId, canCreateDynamicEntityDefinition) + + When(s"a Dynamic Query is created at the hyphenated form of the entity's name, /${hyphenated(name)}/summary") + val refused = makePostRequest(dynamicResourceDocsAt(bankId).POST <@ (user1), queryDocBody(bankId, s"/${hyphenated(name)}/summary", name, s"likeThisSummary$suffix")) + Then("it is refused, naming the entity") + withClue(refused.body) { refused.code should equal(409) } + message(refused) should include(DynamicPathAmbiguous) + message(refused) should include(s"the Dynamic Entity $name") + + Given("a Dynamic Query at a hyphenated path no entity uses yet") + val later = s"later_entity_$suffix" + val doc = makePostRequest(dynamicResourceDocsAt(bankId).POST <@ (user1), queryDocBody(bankId, s"/${hyphenated(later)}/names", name, s"laterNames$suffix")) + withClue(doc.body) { doc.code should equal(201) } + When(s"a Dynamic Entity named $later is created") + val definition = compact(render(("entity_name" -> later) ~ ("has_personal_entity" -> false) ~ + ("schema" -> (("description" -> "Later.") ~ ("required" -> List("name")) ~ + ("properties" -> ("name" -> (("type" -> "string") ~ ("example" -> "a name")))))))) + val refusedEntity = makePostRequest((baseRequest / "obp" / "v6.0.0" / "management" / "banks" / bankId / "dynamic-entities").POST <@ (user1), definition) + Then("it is refused, naming the doc") + withClue(refusedEntity.body) { refusedEntity.code should equal(409) } + message(refusedEntity) should include(DynamicPathAmbiguous) + message(refusedEntity) should include(s"/${hyphenated(later)}/names") + } + + scenario("a new Dynamic Entity name with a hyphen or a space is refused, also in v4.0.0") { + val bankId = testBankId1.value + grantAt(bankId, canCreateDynamicEntityDefinition) + def v4Definition(entityName: String) = compact(render((entityName -> + (("description" -> "A name with a hyphen.") ~ ("required" -> List("name")) ~ + ("properties" -> ("name" -> (("type" -> "string") ~ ("example" -> "a name")))))) ~ ("hasPersonalEntity" -> false))) + val v4Entities = baseRequest / "obp" / "v4.0.0" / "management" / "banks" / bankId / "dynamic-entities" + + When("v4.0.0 is asked to create an entity whose name has a hyphen") + val hyphen = makePostRequest(v4Entities.POST <@ (user1), v4Definition(s"with-hyphen-$suffix")) + Then("it is refused") + withClue(hyphen.body) { hyphen.code should equal(400) } + message(hyphen) should include(DynamicEntityNameHasHyphenOrSpace) + + When("v4.0.0 is asked to create an entity whose name has a space") + val space = makePostRequest(v4Entities.POST <@ (user1), v4Definition(s"with space $suffix")) + Then("it is refused") + space.code should equal(400) + + When("the same name with underscores is created") + val accepted = makePostRequest(v4Entities.POST <@ (user1), v4Definition(s"with_hyphen_$suffix")) + Then("it is accepted") + withClue(accepted.body) { accepted.code should equal(201) } + } + scenario("under a Domain API a Dynamic Resource Doc is tried before a Dynamic Entity") { val bankId = testBankId1.value grantAllAt(bankId) diff --git a/release_notes.md b/release_notes.md index d2517dc253..8381c983ea 100644 --- a/release_notes.md +++ b/release_notes.md @@ -10,6 +10,10 @@ Date Commit Action description, url and openapi_url. POST and PUT .../management/banks/BANK_ID/ domain-apis take it as an optional field: false when left out on create, unchanged when left out on update. The management responses include it. + ADDED: public GET /obp/v7.0.0/domain-apis lists the publicly discoverable + Domain APIs of every space. Public GET /obp/v7.0.0/banks/BANK_ID/domain-apis/ + resource-docs?base_path=BASE_PATH gives a Domain API's endpoints as OBP + Resource Docs, at their full published paths, for API Explorer. 07/10/2026 TBD CHANGED: GET /management/aggregate-metrics (v3.0.0, v5.1.0 and v6.0.0) covers at most 31 days of metrics per call, set by the new prop aggregate_metrics_max_days (default 31). A range between from_date and From 84567641c32f486ce6edfc0eb2869dad5abeaa48 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 13 Oct 2026 19:54:40 +0200 Subject: [PATCH 3/3] testfix: DomainApisTest looked for the record id and name at the top of the response, but a v7.0.0 Dynamic Entity record response puts them inside an object named after the entity, so the test now looks there (DomainApisTest) --- obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala index 32d25d9654..2b45bc5eeb 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala @@ -430,11 +430,12 @@ class DomainApisTest extends V600ServerSetup { withClue(registered.body) { registered.code should equal(201) } def at(segments: String*) = segments.foldLeft(under(basePath))(_ / _) - def idOf(response: code.setup.APIResponse) = (response.body \ s"${name}_id") match { + // A v7.0.0 record response holds the record under the entity's name, next to its metadata. + def idOf(response: code.setup.APIResponse) = (response.body \ name \ s"${name}_id") match { case JString(id) => id case other => fail(s"no ${name}_id in ${response.body}: $other") } - def nameOf(response: code.setup.APIResponse) = (response.body \ "name") match { + def nameOf(response: code.setup.APIResponse) = (response.body \ name \ "name") match { case JString(value) => value case other => fail(s"no name in ${response.body}: $other") }