diff --git a/.github/workflows/build_container.yml b/.github/workflows/build_container.yml index f9f87b6762..9893d4d4e6 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -289,6 +289,9 @@ jobs: echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props echo hikari.maximumPoolSize=20 >> obp-api/src/main/resources/props/test.default.props + # Enables the Berlin Group v1.3 alias so ResourceDocRegistryParityTest and + # ApiCollectionEndpointTest can exercise a real alias operation id end to end. + echo berlin_group_v1_3_alias_path=0.6/v1 >> obp-api/src/main/resources/props/test.default.props echo write_metrics=false >> obp-api/src/main/resources/props/test.default.props # Log emails instead of opening a real SMTP socket: without this, # LocalMappedConnector.sendCustomerNotification's EMAIL branch calls diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 39716fe115..c18b9ae0d8 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -283,6 +283,9 @@ jobs: echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props echo hikari.maximumPoolSize=20 >> obp-api/src/main/resources/props/test.default.props + # Enables the Berlin Group v1.3 alias so ResourceDocRegistryParityTest and + # ApiCollectionEndpointTest can exercise a real alias operation id end to end. + echo berlin_group_v1_3_alias_path=0.6/v1 >> obp-api/src/main/resources/props/test.default.props echo write_metrics=false >> obp-api/src/main/resources/props/test.default.props # Log emails instead of opening a real SMTP socket: without this, # LocalMappedConnector.sendCustomerNotification's EMAIL branch calls diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala index b14f064629..a35a0483a4 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala @@ -3,8 +3,6 @@ package code.api.ResourceDocs1_4_0 import code.api.Constant.{GET_DYNAMIC_RESOURCE_DOCS_TTL, GET_STATIC_RESOURCE_DOCS_TTL, HostName, PARAM_LOCALE} import code.api.OBPRestHelper import code.api.cache.Caching -import code.api.dynamic.endpoint.OBPAPIDynamicEndpoint -import code.api.dynamic.entity.OBPAPIDynamicEntity import code.api.util.APIUtil._ import code.api.util.ApiRole.{canReadDynamicResourceDocsAtOneBank, canReadResourceDoc} import code.api.util.ApiTag._ @@ -22,7 +20,6 @@ import code.api.v4_0_0.{APIMethods400, OBPAPI4_0_0} import code.api.v5_0_0.OBPAPI5_0_0 import code.api.v5_1_0.OBPAPI5_1_0 import code.api.v6_0_0.OBPAPI6_0_0 -import code.api.berlin.group.ConstantsBG import code.apicollectionendpoint.MappedApiCollectionEndpointsProvider import code.util.Helper import code.util.Helper.{MdcLoggable, ObpS, SILENCE_IS_GOLDEN} @@ -331,59 +328,15 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth logger.debug(s"getResourceDocsList says requestedApiVersion is $requestedApiVersion") - val resourceDocs = requestedApiVersion match { - case ApiVersion.v7_0_0 => code.api.v7_0_0.Http4s700.allResourceDocs // Use aggregated docs for v7.0.0 - case ConstantsBG.`berlinGroupVersion1` => code.api.berlin.group.v1_3.Http4sBGv13.resourceDocs - case ConstantsBG.`berlinGroupVersion2` => code.api.berlin.group.v2.Http4sBGv2.resourceDocs - case ApiVersion.v6_0_0 => code.api.util.http4s.Http4sResourceDocAggregation.v600 - case ApiVersion.v5_1_0 => code.api.util.http4s.Http4sResourceDocAggregation.v510 - case ApiVersion.v5_0_0 => code.api.util.http4s.Http4sResourceDocAggregation.v500 - case ApiVersion.v4_0_0 => code.api.util.http4s.Http4sResourceDocAggregation.v400 - case ApiVersion.v3_1_0 => code.api.util.http4s.Http4sResourceDocAggregation.v310 - case ApiVersion.v3_0_0 => code.api.util.http4s.Http4sResourceDocAggregation.v300 - case ApiVersion.v2_2_0 => code.api.util.http4s.Http4sResourceDocAggregation.v220 - case ApiVersion.v2_1_0 => code.api.util.http4s.Http4sResourceDocAggregation.v210 - case ApiVersion.v2_0_0 => code.api.util.http4s.Http4sResourceDocAggregation.v200 - case ApiVersion.v1_4_0 => code.api.util.http4s.Http4sResourceDocAggregation.v140 - case ApiVersion.v1_3_0 => code.api.util.http4s.Http4sResourceDocAggregation.v130 - case ApiVersion.v1_2_1 => code.api.util.http4s.Http4sResourceDocAggregation.v121 - case ApiVersion.`dynamic-endpoint` => OBPAPIDynamicEndpoint.allResourceDocs - case ApiVersion.`dynamic-entity` => OBPAPIDynamicEntity.allResourceDocs - case version: ScannedApiVersion => ScannedApis.versionMapScannedApis.get(version).map(_.allResourceDocs).getOrElse(ArrayBuffer.empty[ResourceDoc]) - case _ => ArrayBuffer.empty[ResourceDoc] - } + // ResourceDocRegistry is the single source of truth for both this per-version dispatch and + // APIUtil.allStaticResourceDocs' global operation-id union -- see that object's doc comment. + val resourceDocs = ResourceDocRegistry.docsFor(requestedApiVersion) logger.debug(s"There are ${resourceDocs.length} resource docs available to $requestedApiVersion") - val activeResourceDocs = requestedApiVersion match { - case ApiVersion.v7_0_0 => resourceDocs - case ConstantsBG.`berlinGroupVersion1` => resourceDocs // fully on http4s — no Lift route filter - case ConstantsBG.`berlinGroupVersion2` => resourceDocs - case ApiVersion.v1_2_1 => resourceDocs - case ApiVersion.v6_0_0 => resourceDocs // fully on http4s — no Lift route filter - case ApiVersion.v5_1_0 => resourceDocs // fully on http4s — no Lift route filter - case ApiVersion.v5_0_0 => resourceDocs // fully on http4s — no Lift route filter - case ApiVersion.v4_0_0 => resourceDocs // fully on http4s — no Lift route filter - case ApiVersion.v3_1_0 => resourceDocs // fully on http4s — no Lift route filter - case ApiVersion.v3_0_0 => resourceDocs // fully on http4s — no Lift route filter - case ApiVersion.v2_2_0 => resourceDocs // fully on http4s — no Lift route filter - case ApiVersion.v2_1_0 => resourceDocs // fully on http4s — no Lift route filter - case ApiVersion.v2_0_0 => resourceDocs // fully on http4s — no Lift route filter - case ApiVersion.v1_4_0 => resourceDocs // fully on http4s — no Lift route filter - case ApiVersion.v1_3_0 => resourceDocs // fully on http4s — no Lift route filter - case ApiVersion.`dynamic-entity` => resourceDocs // runtime CRUD now on Http4sDynamicEntity; routes are Nil, skip Lift-route filter - case ApiVersion.`dynamic-endpoint` => resourceDocs // dispatch now on Http4sDynamicEndpoint (proxy + native Piece C); routes carry only the stub, skip Lift-route filter - case ApiVersion.ukOpenBankingV20 => resourceDocs // fully on http4s — no Lift route filter - case ApiVersion.ukOpenBankingV31 => resourceDocs // fully on http4s — no Lift route filter - case _ => resourceDocs - } - - logger.debug(s"There are ${activeResourceDocs.length} resource docs available to $requestedApiVersion") - - val activePlusLocalResourceDocs = ArrayBuffer[ResourceDoc]() - activePlusLocalResourceDocs ++= activeResourceDocs + activePlusLocalResourceDocs ++= resourceDocs requestedApiVersion match { // only `obp` standard show the `localResourceDocs` diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3_Alias.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3_Alias.scala index 2beba04807..8fc872c200 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3_Alias.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3_Alias.scala @@ -47,8 +47,22 @@ import scala.collection.mutable.ArrayBuffer */ object OBP_BERLIN_GROUP_1_3_Alias extends OBPRestHelper with MdcLoggable with ScannedApis { + /** + * The version this aggregator registers under. + * + * `berlinGroupV13AliasPath` is empty when `berlin_group_v1_3_alias_path` is unset, so `.head` / + * `.last` must be guarded: this object is instantiated by the ScannedApis classpath scan, which + * catches a throwing companion and merely logs a warning, so an unguarded NoSuchElementException + * would drop the alias silently. Inactive registrations keep the empty-string version they have + * always had, which no request can address and which deliberately does NOT equal + * ConstantsBG.berlinGroupVersion1 -- colliding with the canonical BG v1.3 key would let this + * (doc-less) object win ScannedApis' `.toMap` and blank out /resource-docs/BGv1.3/obp. + */ override val apiVersion: ScannedApiVersion = - ScannedApiVersion(berlinGroupV13AliasPath.head, berlinGroupV13AliasPath.head, berlinGroupV13AliasPath.last) + if (berlinGroupV13AliasPath.nonEmpty) + ScannedApiVersion(berlinGroupV13AliasPath.head, berlinGroupV13AliasPath.head, berlinGroupV13AliasPath.last) + else + ScannedApiVersion("", "", "") val versionStatus: String = ApiVersionStatus.DRAFT.toString diff --git a/obp-api/src/main/scala/code/api/berlin/group/v2/Http4sBGv2.scala b/obp-api/src/main/scala/code/api/berlin/group/v2/Http4sBGv2.scala index 9e2d1ce640..b698cb0dc4 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v2/Http4sBGv2.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v2/Http4sBGv2.scala @@ -4,23 +4,31 @@ import cats.data.{Kleisli, OptionT} import cats.effect._ import code.api.berlin.group.ConstantsBG import code.api.util.APIUtil.ResourceDoc +import code.api.util.ScannedApis import code.api.util.http4s.ResourceDocMiddleware import code.util.Helper.MdcLoggable +import com.openbankproject.commons.util.ScannedApiVersion import org.http4s._ import scala.collection.mutable.ArrayBuffer -object Http4sBGv2 extends MdcLoggable { +object Http4sBGv2 extends MdcLoggable with ScannedApis { type HttpF[A] = OptionT[IO, A] val implementedInApiVersion = ConstantsBG.berlinGroupVersion2 + // ScannedApis discovery marker: makes BGv2 convention-driven like the other Berlin Group / + // UK Open Banking standards, so ResourceDocRegistry picks it up without a hand-maintained entry. + override val apiVersion: ScannedApiVersion = implementedInApiVersion + val resourceDocs: ArrayBuffer[ResourceDoc] = Http4sBGv2AIS.resourceDocs ++ Http4sBGv2PIS.resourceDocs ++ Http4sBGv2PIIS.resourceDocs + override val allResourceDocs: ArrayBuffer[ResourceDoc] = resourceDocs + val allRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => Http4sBGv2AIS.routes(req) .orElse(Http4sBGv2PIS.routes(req)) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 9e45f49432..b9b110654c 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -32,9 +32,6 @@ import cats.effect.IO import code.abacrule.AbacRuleEngine import code.accountholders.AccountHolders import code.api.Constant._ -import code.api.UKOpenBanking.v2_0_0.OBP_UKOpenBanking_200 -import code.api.UKOpenBanking.v3_1_0.OBP_UKOpenBanking_310 -import code.api.UKOpenBanking.v4_0_1.OBP_UKOpenBanking_401 import code.api._ import code.api.berlin.group.ConstantsBG import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{ErrorMessageBG, ErrorMessagesBG} @@ -4759,7 +4756,14 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ } ) - val berlinGroupV13AliasPath = APIUtil.getPropsValue("berlin_group_v1_3_alias_path","").split("/").toList.map(_.trim) + // Empty segments are dropped so that "unset" really means "no alias". Without the filter an + // absent prop yields List("") -- "".split("/") returns Array(""), not an empty array -- which is + // nonEmpty, so every `if (berlinGroupV13AliasPath.nonEmpty)` guard downstream took its ACTIVE + // branch on a default instance: Http4sBGv13Alias published 55 docs stamped with the degenerate + // version ScannedApiVersion("", "", ""), whose operation ids came out as `BG-`, and its + // route bridge matched on the prefix "/" (every path) only to fall through again. + val berlinGroupV13AliasPath = + APIUtil.getPropsValue("berlin_group_v1_3_alias_path","").split("/").toList.map(_.trim).filter(_.nonEmpty) val getAtmsIsPublic = APIUtil.getPropsAsBoolValue("apiOptions.getAtmsIsPublic", true) @@ -4874,29 +4878,13 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ val allowedAnswerTransactionRequestChallengeAttempts = APIUtil.getPropsAsIntValue("answer_transactionRequest_challenge_allowed_attempts").openOr(3) - // Base is the v7 aggregation — the newest OBP-standard surface, which already contains the - // v6.0.0-and-older aggregation plus the v7-only endpoints (deduped by URL/method). Basing on - // the v6 aggregation silently excluded v7-only operation ids from everything that resolves - // operation ids through this list (api-collection endpoint validation, top-apis lookups, ...). - // ResourceDocRegistryParityTest pins the invariant that every per-standard surface the - // resource-docs dispatcher can serve is contained here. - lazy val allStaticResourceDocs = (code.api.v7_0_0.Http4s700.allResourceDocs - ++ OBP_UKOpenBanking_200.allResourceDocs - ++ OBP_UKOpenBanking_310.allResourceDocs - ++ OBP_UKOpenBanking_401.allResourceDocs - // Commented out: Lift endpoints migrated off / removed (Polish, STET, AUOpenBanking, MxOF/CNBV9, BahrainOBF) - // ++ code.api.Polish.v2_1_1_1.OBP_PAPI_2_1_1_1.allResourceDocs - // ++ code.api.STET.v1_4.OBP_STET_1_4.allResourceDocs - // ++ code.api.AUOpenBanking.v1_0_0.ApiCollector.allResourceDocs - // ++ code.api.MxOF.CNBV9_1_0_0.allResourceDocs - // ++ code.api.MxOF.OBP_MXOF_1_0_0.allResourceDocs - // ++ code.api.BahrainOBF.v1_0_0.ApiCollector.allResourceDocs - ++ code.api.berlin.group.v1_3.OBP_BERLIN_GROUP_1_3.allResourceDocs - // BGv2 was missing here even though /resource-docs/BGv2 serves it, so a BGv2 operation id - // (e.g. BGv2-getAccountDetails) failed the getAllResourceDocs membership check that - // api-collection-endpoints (and anything else resolving operation ids) relies on. - ++ code.api.berlin.group.v2.Http4sBGv2.resourceDocs).toList - + // Delegates to ResourceDocRegistry, the single source of truth shared with the per-version + // resource-docs dispatcher (ResourceDocsAPIMethods.getResourceDocsList) -- see that object's + // doc comment for why the two used to drift and how deriving both from one registry fixes it. + // Kept under this name so existing call sites (Http4s400, Http4s600, JSONFactory6.0.0, ...) + // don't need to move. + lazy val allStaticResourceDocs: List[ResourceDoc] = ResourceDocRegistry.allStaticResourceDocs + def allDynamicResourceDocs= (DynamicEntityHelper.doc ++ DynamicEndpointHelper.doc ++ DynamicEndpoints.dynamicResourceDocs).toList def getAllResourceDocs = allStaticResourceDocs ++ allDynamicResourceDocs diff --git a/obp-api/src/main/scala/code/api/util/ApiVersionUtils.scala b/obp-api/src/main/scala/code/api/util/ApiVersionUtils.scala index cc2641c67f..d291130498 100644 --- a/obp-api/src/main/scala/code/api/util/ApiVersionUtils.scala +++ b/obp-api/src/main/scala/code/api/util/ApiVersionUtils.scala @@ -2,7 +2,6 @@ package code.api.util import com.openbankproject.commons.util.ApiVersion._ import com.openbankproject.commons.util.ScannedApiVersion -import code.api.berlin.group.ConstantsBG object ApiVersionUtils { @@ -23,7 +22,6 @@ object ApiVersionUtils { v7_0_0 :: `dynamic-endpoint` :: `dynamic-entity` :: - ConstantsBG.berlinGroupVersion2 :: scannedApis ).distinct @@ -48,7 +46,6 @@ object ApiVersionUtils { case v7_0_0.fullyQualifiedVersion | v7_0_0.apiShortVersion => v7_0_0 case `dynamic-endpoint`.fullyQualifiedVersion | `dynamic-endpoint`.apiShortVersion => `dynamic-endpoint` case `dynamic-entity`.fullyQualifiedVersion | `dynamic-entity`.apiShortVersion => `dynamic-entity` - case version if version == ConstantsBG.berlinGroupVersion2.fullyQualifiedVersion || version == ConstantsBG.berlinGroupVersion2.apiShortVersion => ConstantsBG.berlinGroupVersion2 case version if(scannedApis.map(_.fullyQualifiedVersion).contains(version)) =>scannedApis.filter(_.fullyQualifiedVersion==version).head case version if(scannedApis.map(_.apiShortVersion).contains(version)) diff --git a/obp-api/src/main/scala/code/api/util/ResourceDocRegistry.scala b/obp-api/src/main/scala/code/api/util/ResourceDocRegistry.scala new file mode 100644 index 0000000000..641c02ee48 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/ResourceDocRegistry.scala @@ -0,0 +1,162 @@ +package code.api.util + +import code.api.berlin.group.ConstantsBG +import code.api.util.APIUtil.ResourceDoc +import com.openbankproject.commons.util.ApiVersion._ +import com.openbankproject.commons.util.{ApiStandards, ApiVersion, ScannedApiVersion} + +import scala.collection.immutable.ListMap + +/** + * Single source of truth for "which resource docs does version X serve" — used both by the + * per-version resource-docs dispatcher (ResourceDocsAPIMethods.getResourceDocsList, i.e. + * /resource-docs/{VERSION}/... and API Explorer) and by the global operation-id union + * (allStaticResourceDocs / getAllResourceDocs, used wherever an operation id must be resolved: + * api-collection-endpoint creation, top-apis/popular-apis lookups, metrics, ...). + * + * These used to be two independently hand-maintained registries and drifted three times: Berlin + * Group v2 was served by the dispatcher but missing from the union (BGv2-getAccountDetails could + * not be added to an API collection), the union was based on the v6 aggregation excluding v7-only + * operation ids, and the Berlin Group v1.3 alias was missing from the union too. Deriving both + * from one `registry` map makes that class of drift structurally impossible: add a version once, + * both call sites see it. + * + * Rule for adding a new API standard: implement `with ScannedApis` (see that trait) and it is + * picked up automatically via the `scanned` half of `registry` below — no edit needed here. Only + * standards that cannot be discovered that way (or that need to override the source composing + * function, e.g. the cumulative per-version OBP-standard aggregations) need an `explicit` entry. + * + * Deliberately its own file/object, NOT a member of `APIUtil`: the Implementations* objects for + * each version re-enter `APIUtil` during their own initialization (prop lookups, etc.), so a + * strict `val` living inside `APIUtil` risks a class-init deadlock. Everything here stays `lazy` + * and is first touched at request/test time, well after Props and `ApiVersion.setUrlPrefix` have + * run in Boot. + */ +object ResourceDocRegistry { + + /** version -> that surface's docs. Thunks, not values: the ScannedApis-discovered arms are + * lazy vals themselves and the OBP-standard aggregations are cumulative lazy vals too — wrapping + * in a function defers evaluation to first use of THIS registry, not construction of the map. */ + lazy val registry: ListMap[ApiVersion, () => Seq[ResourceDoc]] = { + val explicit: ListMap[ApiVersion, () => Seq[ResourceDoc]] = ListMap( + v7_0_0 -> (() => code.api.v7_0_0.Http4s700.allResourceDocs.toSeq), + v6_0_0 -> (() => code.api.util.http4s.Http4sResourceDocAggregation.v600.toSeq), + v5_1_0 -> (() => code.api.util.http4s.Http4sResourceDocAggregation.v510.toSeq), + v5_0_0 -> (() => code.api.util.http4s.Http4sResourceDocAggregation.v500.toSeq), + v4_0_0 -> (() => code.api.util.http4s.Http4sResourceDocAggregation.v400.toSeq), + v3_1_0 -> (() => code.api.util.http4s.Http4sResourceDocAggregation.v310.toSeq), + v3_0_0 -> (() => code.api.util.http4s.Http4sResourceDocAggregation.v300.toSeq), + v2_2_0 -> (() => code.api.util.http4s.Http4sResourceDocAggregation.v220.toSeq), + v2_1_0 -> (() => code.api.util.http4s.Http4sResourceDocAggregation.v210.toSeq), + v2_0_0 -> (() => code.api.util.http4s.Http4sResourceDocAggregation.v200.toSeq), + v1_4_0 -> (() => code.api.util.http4s.Http4sResourceDocAggregation.v140.toSeq), + v1_3_0 -> (() => code.api.util.http4s.Http4sResourceDocAggregation.v130.toSeq), + v1_2_1 -> (() => code.api.util.http4s.Http4sResourceDocAggregation.v121.toSeq), + `dynamic-endpoint` -> (() => code.api.dynamic.endpoint.OBPAPIDynamicEndpoint.allResourceDocs.toSeq), + `dynamic-entity` -> (() => code.api.dynamic.entity.OBPAPIDynamicEntity.allResourceDocs.toSeq) + // Neither Berlin Group nor UK Open Banking is listed here: they are all ScannedApis + // registrants, so `scanned` picks them up and -- crucially -- orders them against each other + // by standardPrecedence below. Naming one of them here would pin it ahead of that ordering. + ) + // Every standard discovered via ScannedApis (UK OB 200/310/401, BG v1.3 canonical + alias, + // BG v2, and any future `with ScannedApis` standard), folded into a ListMap so the registry has + // ONE defined iteration order. + // + // Order matters beyond determinism: Http4s600's top-apis/popular-apis and JSONFactory6.0.0's + // metrics build `partialFunctionName -> operationId` with `.toMap`, where the LAST entry wins. + // Berlin Group and UK Open Banking share three partialFunctionNames -- getBalances, + // getAccountList, getAccountBalances -- and the hand-written union this registry replaced + // listed UK before BG, so Berlin Group won all three. Sorting alphabetically put UK last and + // silently flipped them to UKv4.0.1-getBalances / UKv2.0-getAccountList / + // UKv2.0-getAccountBalances in metrics output, so the precedence is now explicit. + val scanned: ListMap[ApiVersion, () => Seq[ResourceDoc]] = + ScannedApis.versionMapScannedApis.toSeq + .collect { case (version: ScannedApiVersion, apis) if !explicit.contains(version) => + version -> (() => apis.allResourceDocs.toSeq) } + .sortBy(entry => sortKey(code.api.berlin.group.v1_3.OBP_BERLIN_GROUP_1_3_Alias.apiVersion)(entry._1)) + .foldLeft(ListMap.empty[ApiVersion, () => Seq[ResourceDoc]])(_ + _) + explicit ++ scanned + } + + /** + * Standards in ASCENDING precedence: a standard later in this list wins a partialFunctionName it + * shares with an earlier one, because the `.toMap` consumers keep the last entry. This + * reproduces the order of the hand-written union that preceded this registry (UK Open Banking, + * then Berlin Group). A standard that is not listed ranks below all of them. + */ + private val standardPrecedence: List[String] = + List(ApiVersion.ukOpenBankingV20.apiStandard, ConstantsBG.berlinGroupVersion1.apiStandard) + + /** Below every entry of standardPrecedence, whose lowest index is -1 for an unlisted standard. */ + private val derivedStandardRank: Int = -2 + + /** + * Total order over registry keys: precedence first, then the version's own identity. + * + * `derivedAliasVersion` is the version of a standard that merely re-publishes another standard's + * docs -- today only the Berlin Group v1.3 alias. It is matched by identity, NOT by its + * apiStandard, because that string is the first segment of `berlin_group_v1_3_alias_path` and a + * deployment may legitimately choose one that an existing standard already uses: configured as + * "BG/v9" the alias would otherwise rank alongside Berlin Group and, sorting after "v2", let its + * re-stamped copies win getBalances, getAccountList and getAccountBalances away from the + * canonical docs it copied. Ranking it derivedStandardRank keeps that impossible for any + * configuration. + * + * The tie-breaker is (apiStandard, apiShortVersion) rather than fullyQualifiedVersion because + * that pair is exactly ScannedApiVersion's equals/hashCode key, so two distinct keys of a Map + * keyed by version always differ in it and sortBy never has to fall back to the unordered input. + * fullyQualifiedVersion concatenates the two (apiStandard.toUpperCase + apiShortVersion) and can + * therefore collide across distinct keys -- ("BG", "v1.3") and ("BGV", "1.3") both render + * "BGV1.3" -- which a deployment could reach through berlin_group_v1_3_alias_path. + * + * Curried and package-private so a test can rank against a synthetic alias without having to + * restart the JVM under a different berlin_group_v1_3_alias_path. + */ + private[util] def sortKey(derivedAliasVersion: ScannedApiVersion) + (version: ScannedApiVersion): (Int, String, String) = { + val rank = + if (version == derivedAliasVersion) derivedStandardRank + else standardPrecedence.indexOf(version.apiStandard) + (rank, version.apiStandard, version.apiShortVersion) + } + + /** What the per-version resource-docs dispatcher serves for this version (empty if unknown). */ + def docsFor(version: ApiVersion): Seq[ResourceDoc] = registry.get(version).map(_ ()).getOrElse(Nil) + + /** + * The OBP-standard surface the global union is built from. + * + * The registry also holds the cumulative aggregations for every older OBP version, because the + * dispatcher must still serve /resource-docs/OBPv4.0.0/obp and friends. Those are NOT folded into + * the union: they are not subsets of the v7 aggregation (an endpoint dropped after v4 keeps its + * operation id there), so including them would add ~287 operation ids that the union never + * carried, 234 of which collide on partialFunctionName with an entry already present -- and the + * `.toMap` consumers above would then report the OLDEST id (getBanks -> OBPv1.2.1-getBanks) + * instead of the current one in metrics, top-apis and popular-apis output. + * + * Consequence, deliberately accepted: an operation id that exists ONLY in a superseded + * aggregation stays unresolvable by api-collection-endpoint creation, exactly as before this + * refactor. ResourceDocRegistryParityTest pins that this constant is the newest OBP-standard + * version in the registry, so adding v8 without moving it fails the build rather than silently + * dropping v8-only operation ids from the union. + */ + val obpUnionVersion: ApiVersion = v7_0_0 + + private def isObpStandard(version: ApiVersion): Boolean = version match { + case sv: ScannedApiVersion => sv.apiStandard == ApiStandards.obp.toString + case _ => false + } + + /** Versions whose docs make up the global union: the current OBP surface plus every non-OBP + * standard. Excluding the other OBP-standard keys also excludes `dynamic-endpoint` / + * `dynamic-entity` (both carry apiStandard "obp"), which must stay out for a second reason: + * they are runtime-mutable and APIUtil.getAllResourceDocs appends them FRESH on every call, so + * caching them in this lazy union would serve stale dynamic docs. */ + lazy val unionVersions: Seq[ApiVersion] = + registry.keys.filter(v => v == obpUnionVersion || !isObpStandard(v)).toSeq + + /** The global operation-id union. Deduped by operationId: the surfaces legitimately overlap, and + * consumers only ever `.find`/build a lookup map from this list, never rely on duplicates. */ + lazy val allStaticResourceDocs: List[ResourceDoc] = + unionVersions.flatMap(docsFor).toList.distinctBy(_.operationId) +} diff --git a/obp-api/src/main/scala/code/api/util/ScannedApis.scala b/obp-api/src/main/scala/code/api/util/ScannedApis.scala index bfbc688985..238de80b0e 100644 --- a/obp-api/src/main/scala/code/api/util/ScannedApis.scala +++ b/obp-api/src/main/scala/code/api/util/ScannedApis.scala @@ -21,9 +21,21 @@ trait ScannedApis { object ScannedApis { /** * this map value are all scanned objects those extends ScannedApiVersion, the key is it apiVersion field + * + * Registrants whose version carries no urlPrefix, apiStandard and apiShortVersion are dropped: + * such a version addresses nothing, and it is how a configuration-gated standard reports itself + * as switched off (OBP_BERLIN_GROUP_1_3_Alias falls back to ScannedApiVersion("", "", "") when + * berlin_group_v1_3_alias_path is unset). Keeping it here leaked into everything built from this + * map: its fullyQualifiedVersion is "" too, so ApiVersionUtils.valueOf("") resolved successfully + * and GET /obp/v7.0.0/resource-docs//obp answered 200 with an empty document list instead of the + * 400 InvalidApiVersionString any other unknown version string gets. */ lazy val versionMapScannedApis: Map[ScannedApiVersion, ScannedApis] = ClassScanUtils.getSubTypeObjects[ScannedApis] + .filter(it => isAddressable(it.apiVersion)) .map(it=> (it.apiVersion, it)) .toMap + + private def isAddressable(version: ScannedApiVersion): Boolean = + version.urlPrefix.trim.nonEmpty || version.apiStandard.trim.nonEmpty || version.apiShortVersion.trim.nonEmpty } diff --git a/obp-api/src/test/scala/code/api/util/ResourceDocRegistryParityTest.scala b/obp-api/src/test/scala/code/api/util/ResourceDocRegistryParityTest.scala index 520d04a223..3816545da8 100644 --- a/obp-api/src/test/scala/code/api/util/ResourceDocRegistryParityTest.scala +++ b/obp-api/src/test/scala/code/api/util/ResourceDocRegistryParityTest.scala @@ -1,21 +1,24 @@ package code.api.util +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.Http4sBGv13Alias import code.setup.ServerSetup +import com.openbankproject.commons.util.{ApiStandards, ApiVersion, ScannedApiVersion} import org.scalatest.Tag /** - * Guards the invariant that APIUtil.getAllResourceDocs — the global operation-id - * registry used wherever an operation id must be resolved (api-collection endpoint - * validation, top-apis operation-id lookups, ...) — contains every per-standard - * resource-doc surface the resource-docs dispatcher can serve to API Explorer. + * Guards the invariant that APIUtil.getAllResourceDocs — the global operation-id registry used + * wherever an operation id must be resolved (api-collection endpoint validation, top-apis + * operation-id lookups, ...) — contains every per-standard resource-doc surface the resource-docs + * dispatcher can serve to API Explorer. * - * These are two parallel registries (ResourceDocsAPIMethods dispatches per - * standard/version; getAllResourceDocs aggregates them all), and they have drifted - * twice: Berlin Group v2 was served by the dispatcher but missing from the global - * registry (so BGv2-getAccountDetails could not be added to an API collection), - * and the global registry was based on the v6 aggregation, excluding v7-only - * operation ids. When you add a NEW API standard, register its docs in BOTH - * places — and add its surface to this list. + * Both sides are now derived from the single ResourceDocRegistry.registry map, so this class of + * drift (which happened three times by hand: Berlin Group v2, v7-only operation ids, and the + * Berlin Group v1.3 alias) is structurally impossible going forward — see ResourceDocRegistry's + * doc comment. This test's job is narrower than it used to be: it iterates the registry itself + * (rather than a hand-typed list of standards) so it stays correct as standards are added or + * removed without needing an edit here, and it catches an accidental regression back to two + * independently hand-maintained registries. */ class ResourceDocRegistryParityTest extends ServerSetup { @@ -24,19 +27,45 @@ class ResourceDocRegistryParityTest extends ServerSetup { private lazy val allOperationIds: Set[String] = APIUtil.getAllResourceDocs.map(_.operationId).toSet - private lazy val surfaces: List[(String, Seq[String])] = List( - ("OBP standard (v7 aggregation)", code.api.v7_0_0.Http4s700.allResourceDocs.map(_.operationId).toSeq), - ("Berlin Group v1.3", code.api.berlin.group.v1_3.Http4sBGv13.resourceDocs.map(_.operationId).toSeq), - ("Berlin Group v2", code.api.berlin.group.v2.Http4sBGv2.resourceDocs.map(_.operationId).toSeq), - ("UK Open Banking 2.0.0", code.api.UKOpenBanking.v2_0_0.OBP_UKOpenBanking_200.allResourceDocs.map(_.operationId).toSeq), - ("UK Open Banking 3.1.0", code.api.UKOpenBanking.v3_1_0.OBP_UKOpenBanking_310.allResourceDocs.map(_.operationId).toSeq), - ("UK Open Banking 4.0.1", code.api.UKOpenBanking.v4_0_1.OBP_UKOpenBanking_401.allResourceDocs.map(_.operationId).toSeq) - ) + // The Berlin Group v1.3 alias is the one surface a deployment can switch off: + // berlin_group_v1_3_alias_path is unset by default and is supplied for test runs by + // test.default.props and by the two CI workflows. test.default.props is gitignored + // (.gitignore:21), so a fresh clone, a colleague's checkout or an IDE ScalaTest run may not have + // it -- the pin below cancels there instead of failing with a message that gives no hint a prop + // is missing. The per-surface loop needs no such guard: an unconfigured alias reports the + // unaddressable ScannedApiVersion("", "", ""), which ScannedApis now drops, so it is not a + // surface at all rather than an empty one. + private lazy val aliasIsConfigured: Boolean = Http4sBGv13Alias.resourceDocs.nonEmpty + private val aliasNotConfigured = + "berlin_group_v1_3_alias_path is not set, so the Berlin Group v1.3 alias contributes no docs" + + private def label(version: ApiVersion): String = version match { + case sv: ScannedApiVersion => sv.fullyQualifiedVersion + case other => other.toString + } + + // Scoped to ResourceDocRegistry.unionVersions -- the current OBP surface plus every non-OBP + // standard. The superseded OBP aggregations (v6.0.0 and older) and the two dynamic arms are + // deliberately out of the union; see ResourceDocRegistry.obpUnionVersion for why, and for the + // accepted consequence that an operation id living only in a superseded aggregation stays + // unresolvable. + private lazy val surfaces: List[(String, Seq[String])] = + ResourceDocRegistry.unionVersions.toList + .map(version => (label(version), ResourceDocRegistry.docsFor(version).map(_.operationId))) + + feature("getAllResourceDocs contains every per-standard resource-doc surface the union covers") { + scenario("the registry itself is non-empty", RegistryParityTag) { + surfaces should not be empty + } - feature("getAllResourceDocs contains every per-standard resource-doc surface") { surfaces.foreach { case (label, operationIds) => scenario(s"$label operation ids are all resolvable globally", RegistryParityTag) { - operationIds should not be empty + // Non-empty matters as much as membership: an empty surface is trivially a subset of the + // union, so without this a standard whose docs silently stop being registered (the very + // failure mode this test exists for) would pass unnoticed. + withClue(s"$label contributed no operation ids at all -- did its docs stop being registered? ") { + operationIds should not be empty + } val missing = operationIds.filterNot(allOperationIds.contains) withClue(s"$label operation ids missing from getAllResourceDocs: ${missing.take(10).mkString(", ")} ") { missing shouldBe empty @@ -44,8 +73,113 @@ class ResourceDocRegistryParityTest extends ServerSetup { } } + // Guards the one hand-maintained knob left in the registry: if a v8.0.0 aggregation is added + // without moving obpUnionVersion, the union would keep serving the v7 surface and every + // v8-only operation id would silently be unresolvable -- the exact bug this PR started from. + scenario("obpUnionVersion is the newest OBP-standard version in the registry", RegistryParityTag) { + val obpVersions = ResourceDocRegistry.registry.keys.toList.collect { + case sv: ScannedApiVersion + if sv.apiStandard == ApiStandards.obp.toString && + sv != ApiVersion.`dynamic-endpoint` && sv != ApiVersion.`dynamic-entity` => sv + } + obpVersions should not be empty + + // Rank by position in ApiVersionUtils.versions, which lists the OBP versions oldest-first. + // indexOf returns -1 for anything absent from that (also hand-maintained) list, and a -1 + // would lose every maxBy comparison -- so a v8.0.0 added to the registry but not to + // ApiVersionUtils.versions would leave v7 as the maximum and let this scenario pass, in + // exactly the two-places-to-edit case it exists to catch. Establish coverage first. + val unranked = obpVersions.filter(ApiVersionUtils.versions.indexOf(_) < 0) + withClue(s"OBP versions in the registry but missing from ApiVersionUtils.versions: " + + s"${unranked.map(_.fullyQualifiedVersion).mkString(", ")} -- add them there so they can be " + + s"ranked, otherwise this guard cannot see them ") { + unranked shouldBe empty + } + + val newest = obpVersions.maxBy(ApiVersionUtils.versions.indexOf(_)) + withClue(s"registry holds OBP versions ${obpVersions.map(_.fullyQualifiedVersion).mkString(", ")} " + + s"but obpUnionVersion is ${ResourceDocRegistry.obpUnionVersion} ") { + newest shouldBe ResourceDocRegistry.obpUnionVersion + } + } + + // Berlin Group and UK Open Banking both publish getBalances, getAccountList and + // getAccountBalances. Http4s600's top-apis/popular-apis and JSONFactory6.0.0's metrics resolve + // a partialFunctionName with `.toMap`, which keeps the LAST matching entry, so the registry's + // iteration order decides the operation_id those endpoints report. The hand-written union that + // preceded this registry listed UK before BG, giving Berlin Group the three names; sorting the + // scanned standards alphabetically silently handed them to UK Open Banking instead. This pins + // the resolved values so the precedence cannot drift again unnoticed. + scenario("Berlin Group keeps the partialFunctionNames it shares with UK Open Banking", RegistryParityTag) { + val resolved = APIUtil.getAllResourceDocs + .map(doc => doc.partialFunctionName -> doc.operationId).toMap + resolved.get("getBalances") shouldBe Some("BGv1.3-getBalances") + resolved.get("getAccountList") shouldBe Some("BGv2-getAccountList") + resolved.get("getAccountBalances") shouldBe Some("BGv2-getAccountBalances") + } + + // The Berlin Group v1.3 alias only re-publishes the canonical BG v1.3 docs, so it must never + // win a partialFunctionName away from the standard it copied. Its apiStandard is the first + // segment of berlin_group_v1_3_alias_path, so a deployment can point it at a name an existing + // standard already uses ("BG/v9"); ranking by that string alone put the alias alongside Berlin + // Group and, sorting after "v2", ahead of it. Ranking is by identity instead, and the synthetic + // alias below exercises the colliding configuration without needing a JVM under that prop. + scenario("a derived alias never outranks the standard it re-publishes", RegistryParityTag) { + val syntheticAlias = ScannedApiVersion("BG", "BG", "v9") + val rankOf = ResourceDocRegistry.sortKey(syntheticAlias) _ + withClue("the alias must sort before Berlin Group, i.e. lose the `.toMap` last-wins race ") { + rankOf(syntheticAlias) should be < rankOf(ConstantsBG.berlinGroupVersion2) + rankOf(syntheticAlias) should be < rankOf(ConstantsBG.berlinGroupVersion1) + } + withClue("the alias must also sort before UK Open Banking ") { + rankOf(syntheticAlias) should be < rankOf(ApiVersion.ukOpenBankingV401) + } + withClue("UK must still sort before Berlin Group, so BG keeps the names they share ") { + rankOf(ApiVersion.ukOpenBankingV401) should be < rankOf(ConstantsBG.berlinGroupVersion2) + } + } + + // An unconfigured configuration-gated standard reports ScannedApiVersion("", "", ""), whose + // fullyQualifiedVersion is "" as well. While ScannedApis kept it, ApiVersionUtils.valueOf("") + // resolved successfully and GET /obp/v7.0.0/resource-docs//obp answered 200 with an empty + // document list instead of the 400 every other unknown version string gets. + scenario("an unaddressable empty version is not a registered API version", RegistryParityTag) { + ScannedApis.versionMapScannedApis.keys.foreach { version => + withClue(s"$version was registered despite addressing nothing ") { + (version.urlPrefix.trim + version.apiStandard.trim + version.apiShortVersion.trim) should not be empty + } + } + ApiVersionUtils.versions.map(_.fullyQualifiedVersion) should not contain "" + an[IllegalArgumentException] should be thrownBy ApiVersionUtils.valueOf("") + } + + // The three named pins below are the three historical drift instances. They are NOT redundant + // with the loop above: both sides of that loop are now derived from ResourceDocRegistry, so its + // membership half holds by construction and cannot fail. What the loop still catches is a + // surface going empty; what these pins still catch is a specific operation id disappearing. + scenario("the operation id from the sandbox bug report resolves", RegistryParityTag) { allOperationIds should contain("BGv2-getAccountDetails") } + + // The alias's operation-id prefix is derived from the configured path (0.6/v1 in the test props + // yields BGv1-...), so the expected id is read back from the alias's own docs rather than + // hard-coded -- a deployment that configures a different path would otherwise fail here for no + // real reason. + scenario("the operation id from the Berlin Group v1.3 alias resolves", RegistryParityTag) { + if (!aliasIsConfigured) cancel(aliasNotConfigured) + val aliasOperationId = Http4sBGv13Alias.resourceDocs + .find(_.partialFunctionName == "getPaymentInitiationStatus").map(_.operationId) + withClue("the alias is configured but publishes no getPaymentInitiationStatus doc ") { + aliasOperationId shouldBe defined + } + allOperationIds should contain(aliasOperationId.get) + } + + // The union used to be built from the v6.0.0 aggregation, so v7-only operation ids were + // absent from it. getMyMetrics exists only in v7.0.0, so it pins the v7 base specifically. + scenario("a v7-only operation id resolves", RegistryParityTag) { + allOperationIds should contain("OBPv7.0.0-getMyMetrics") + } } } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/ApiCollectionEndpointTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/ApiCollectionEndpointTest.scala index 5e57ea2a7e..26d40aa469 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/ApiCollectionEndpointTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/ApiCollectionEndpointTest.scala @@ -27,6 +27,7 @@ package code.api.v4_0_0 import org.json4s._ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON +import code.api.berlin.group.v1_3.Http4sBGv13Alias import code.api.util.APIUtil.OAuth._ import code.api.v4_0_0.APIMethods400.Implementations4_0_0 import com.github.dwickern.macros.NameOf.nameOf @@ -202,7 +203,79 @@ class ApiCollectionEndpointTest extends V400ServerSetup { val operationId= apiCollectionEndpoint.operation_id } - + + { + // Regression pin for the sandbox bug report: BGv2-getAccountDetails was served by the + // resource-docs dispatcher (/resource-docs/BGv2/obp) but missing from the global + // operation-id union getAllResourceDocs relies on, so this exact request used to fail + // with OBP-40048 Invalid operation_id. + Then(s"we test the $ApiEndpoint6- BGv2-getAccountDetails") + val requestApiCollectionEndpoint = (v4_0_0_Request / "my" / "api-collection-ids" / apiCollectionId / "api-collection-endpoints").POST <@ (user1) + + lazy val postApiCollectionEndpointJson = SwaggerDefinitionsJSON.postApiCollectionEndpointJson400.copy(operation_id="BGv2-getAccountDetails") + + val responseApiCollectionEndpointJson = makePostRequest(requestApiCollectionEndpoint, write(postApiCollectionEndpointJson)) + Then("We should get a 201") + responseApiCollectionEndpointJson.code should equal(201) + val apiCollectionEndpoint = responseApiCollectionEndpointJson.body.extract[ApiCollectionEndpointJson400] + + apiCollectionEndpoint.operation_id should be (postApiCollectionEndpointJson.operation_id) + apiCollectionEndpoint.api_collection_endpoint_id shouldNot be (null) + + val operationId= apiCollectionEndpoint.operation_id + } + + // Regression pin for the Berlin Group v1.3 alias gap: when berlin_group_v1_3_alias_path is + // set (0.6/v1 in test.default.props and in both CI workflows) Http4sBGv13Alias publishes + // re-stamped copies of the canonical BG v1.3 docs under their own operation ids -- served by + // the resource-docs dispatcher via ScannedApis discovery, but formerly missing from the + // global operation-id union, the same class of gap as BGv2 above. + // + // Guarded on the alias actually being configured, and the expected id is read back from its + // own docs rather than hard-coded: test.default.props is gitignored (.gitignore:21), so a + // fresh clone or an IDE runner may not carry that prop, and a deployment may configure a + // different path (which changes the id's prefix). + val aliasOperationId: Option[String] = Http4sBGv13Alias.resourceDocs + .find(_.partialFunctionName == "getPaymentInitiationStatus").map(_.operationId) + + aliasOperationId.foreach { opId => + Then(s"we test the $ApiEndpoint6- $opId (Berlin Group v1.3 alias)") + val requestApiCollectionEndpoint = (v4_0_0_Request / "my" / "api-collection-ids" / apiCollectionId / "api-collection-endpoints").POST <@ (user1) + + lazy val postApiCollectionEndpointJson = SwaggerDefinitionsJSON.postApiCollectionEndpointJson400.copy(operation_id = opId) + + val responseApiCollectionEndpointJson = makePostRequest(requestApiCollectionEndpoint, write(postApiCollectionEndpointJson)) + Then("We should get a 201") + responseApiCollectionEndpointJson.code should equal(201) + val apiCollectionEndpoint = responseApiCollectionEndpointJson.body.extract[ApiCollectionEndpointJson400] + + apiCollectionEndpoint.operation_id should be (postApiCollectionEndpointJson.operation_id) + apiCollectionEndpoint.api_collection_endpoint_id shouldNot be (null) + } + + { + // Regression pin for the third drift instance: the global operation-id union used to be + // built from the v6.0.0 aggregation, so operation ids belonging to endpoints that exist + // ONLY in v7.0.0 (getMyMetrics, getTopUsers, getTopConsumers) were absent from it and + // could not be added to an API collection either. getMyMetrics is v7-only -- it is not + // part of Http4sResourceDocAggregation.v600 -- so this pins the v7 base specifically, + // unlike the OBPv6.0.0-* cases above which passed even under the old v6-based union. + Then(s"we test the $ApiEndpoint6- OBPv7.0.0-getMyMetrics (v7-only endpoint)") + val requestApiCollectionEndpoint = (v4_0_0_Request / "my" / "api-collection-ids" / apiCollectionId / "api-collection-endpoints").POST <@ (user1) + + lazy val postApiCollectionEndpointJson = SwaggerDefinitionsJSON.postApiCollectionEndpointJson400.copy(operation_id="OBPv7.0.0-getMyMetrics") + + val responseApiCollectionEndpointJson = makePostRequest(requestApiCollectionEndpoint, write(postApiCollectionEndpointJson)) + Then("We should get a 201") + responseApiCollectionEndpointJson.code should equal(201) + val apiCollectionEndpoint = responseApiCollectionEndpointJson.body.extract[ApiCollectionEndpointJson400] + + apiCollectionEndpoint.operation_id should be (postApiCollectionEndpointJson.operation_id) + apiCollectionEndpoint.api_collection_endpoint_id shouldNot be (null) + + val operationId= apiCollectionEndpoint.operation_id + } + { Then(s"we test the $ApiEndpoint7") val requestGet = (v4_0_0_Request / "my" / "api-collection-ids" / apiCollectionId / "api-collection-endpoints").GET <@ (user1) @@ -213,7 +286,10 @@ class ApiCollectionEndpointTest extends V400ServerSetup { val apiCollectionsJsonGet400 = responseGet.body.extract[ApiCollectionEndpointsJson400] - apiCollectionsJsonGet400.api_collection_endpoints.length should be (4) + // Six unconditional cases above, plus the Berlin Group v1.3 alias one when that alias is + // configured for this run. + val expected = if (aliasOperationId.isDefined) 7 else 6 + apiCollectionsJsonGet400.api_collection_endpoints.length should be (expected) } } }