Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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] =
Expand All @@ -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))
Expand All @@ -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
}

/**
Expand All @@ -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
}

Expand All @@ -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] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
}
}
Expand All @@ -117,6 +124,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
Expand Down
3 changes: 2 additions & 1 deletion obp-api/src/main/scala/code/api/util/ErrorMessages.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading