diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index c4898f28ff..7b0d3c0eec 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala @@ -2,6 +2,8 @@ package code.api.util.http4s import cats.data.{Kleisli, OptionT} import cats.effect.IO +import code.api.util.APIUtil +import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} import org.http4s._ import org.typelevel.ci.CIString @@ -50,6 +52,24 @@ object Http4sApp { } } + // Whole-version gates: short-circuit to empty when api_disabled_versions / api_enabled_versions + // exclude a version, so the entire vN.N.N http4s chain is bypassed without per-request cost. + // Evaluated once at object init, matching Lift's startup-only evaluation in enableVersionIfAllowed. + // The per-endpoint disable check still runs inside ResourceDocMiddleware for finer-grained Props + // (api_disabled_endpoints / api_enabled_endpoints). + private def gate(version: ScannedApiVersion, routes: HttpRoutes[IO]): HttpRoutes[IO] = + if (APIUtil.versionIsAllowed(version)) routes else HttpRoutes.empty[IO] + + private val v121Routes: HttpRoutes[IO] = gate(ApiVersion.v1_2_1, code.api.v1_2_1.Http4s121.wrappedRoutesV121Services) + private val v130Routes: HttpRoutes[IO] = gate(ApiVersion.v1_3_0, code.api.v1_3_0.Http4s130.wrappedRoutesV130Services) + private val v140Routes: HttpRoutes[IO] = gate(ApiVersion.v1_4_0, code.api.v1_4_0.Http4s140.wrappedRoutesV140Services) + private val v200Routes: HttpRoutes[IO] = gate(ApiVersion.v2_0_0, code.api.v2_0_0.Http4s200.wrappedRoutesV200Services) + private val v210Routes: HttpRoutes[IO] = gate(ApiVersion.v2_1_0, code.api.v2_1_0.Http4s210.wrappedRoutesV210Services) + private val v220Routes: HttpRoutes[IO] = gate(ApiVersion.v2_2_0, code.api.v2_2_0.Http4s220.wrappedRoutesV220Services) + private val v300Routes: HttpRoutes[IO] = gate(ApiVersion.v3_0_0, code.api.v3_0_0.Http4s300.wrappedRoutesV300Services) + private val v500Routes: HttpRoutes[IO] = gate(ApiVersion.v5_0_0, code.api.v5_0_0.Http4s500.wrappedRoutesV500Services) + private val v700Routes: HttpRoutes[IO] = gate(ApiVersion.v7_0_0, code.api.v7_0_0.Http4s700.wrappedRoutesV700Services) + /** * Build the base HTTP4S routes with priority-based routing */ @@ -57,16 +77,16 @@ object Http4sApp { corsHandler.run(req) .orElse(AppsPage.routes.run(req)) .orElse(StatusPage.routes.run(req)) - .orElse(code.api.v5_0_0.Http4s500.wrappedRoutesV500Services.run(req)) - .orElse(code.api.v7_0_0.Http4s700.wrappedRoutesV700Services.run(req)) + .orElse(v500Routes.run(req)) + .orElse(v700Routes.run(req)) .orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req)) - .orElse(code.api.v3_0_0.Http4s300.wrappedRoutesV300Services.run(req)) - .orElse(code.api.v2_2_0.Http4s220.wrappedRoutesV220Services.run(req)) - .orElse(code.api.v2_1_0.Http4s210.wrappedRoutesV210Services.run(req)) - .orElse(code.api.v2_0_0.Http4s200.wrappedRoutesV200Services.run(req)) - .orElse(code.api.v1_4_0.Http4s140.wrappedRoutesV140Services.run(req)) - .orElse(code.api.v1_3_0.Http4s130.wrappedRoutesV130Services.run(req)) - .orElse(code.api.v1_2_1.Http4s121.wrappedRoutesV121Services.run(req)) + .orElse(v300Routes.run(req)) + .orElse(v220Routes.run(req)) + .orElse(v210Routes.run(req)) + .orElse(v200Routes.run(req)) + .orElse(v140Routes.run(req)) + .orElse(v130Routes.run(req)) + .orElse(v121Routes.run(req)) .orElse(Http4sLiftWebBridge.routes.run(req)) } diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index f778a2c8a0..6ae7cfe362 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -11,7 +11,7 @@ import code.api.util.newstyle.ViewNewStyle import code.api.util.{APIUtil, ApiRole, CallContext, NewStyle} import code.util.Helper.MdcLoggable import com.openbankproject.commons.model._ -import com.openbankproject.commons.util.ApiShortVersions +import com.openbankproject.commons.util.{ApiShortVersions, ScannedApiVersion} import com.github.dwickern.macros.NameOf.nameOf import net.liftweb.common.{Box, Empty, Failure, Full} import org.http4s._ @@ -88,6 +88,28 @@ object ResourceDocMiddleware extends MdcLoggable { } } + /** + * Pure decision: is this ResourceDoc enabled given the four enable/disable Props? + * + * Semantics — matches `APIUtil.getAllowedResourceDocs` / `versionIsAllowed`: + * - if operationId is in disabledOperationIds → disabled + * - if enabledOperationIds non-empty and op not in it → disabled + * - if version is not allowed → disabled + * - otherwise → enabled + * + * Extracted from `apply` so the decision can be unit-tested without standing up + * a middleware instance or mutating global Props. + */ + def isEndpointEnabled( + rd: ResourceDoc, + disabledOperationIds: Set[String], + enabledOperationIds: Set[String], + versionAllowed: ScannedApiVersion => Boolean + ): Boolean = + !disabledOperationIds.contains(rd.operationId) && + (enabledOperationIds.isEmpty || enabledOperationIds.contains(rd.operationId)) && + versionAllowed(rd.implementedInApiVersion) + /** * Middleware factory: wraps HttpRoutes with ResourceDoc validation. * Finds the matching ResourceDoc, validates the request, and enriches CallContext. @@ -96,6 +118,17 @@ object ResourceDocMiddleware extends MdcLoggable { // Build the lookup index once per middleware instance (at startup), not per request. val resourceDocIndex = ResourceDocMatcher.buildIndex(resourceDocs) Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => + // Read enable/disable Props per request so runtime changes (e.g. `setPropsValues` in + // tests or live config reloads) take effect immediately. Cost is a few Lift Props + // lookups — negligible per request, but lets disabled endpoints/versions be toggled + // without restarting the server. A disabled endpoint or version yields OptionT.none + // so the request falls through to the next handler in the chain (typically the Lift + // bridge), mirroring the absent-route behavior of Lift's startup filter. + val disabledOperationIds = APIUtil.getDisabledEndpointOperationIds().toSet + val enabledOperationIds = APIUtil.getEnabledEndpointOperationIds().toSet + def endpointIsEnabled(rd: ResourceDoc): Boolean = + isEndpointEnabled(rd, disabledOperationIds, enabledOperationIds, + v => APIUtil.versionIsAllowed(v)) val apiVersionFromPath = req.uri.path.segments.map(_.encoded).toList match { case apiPathZero :: version :: _ if apiPathZero == APIUtil.getPropsValue("apiPathZero", "obp") => version case _ => ApiShortVersions.`v7.0.0`.toString @@ -103,6 +136,10 @@ object ResourceDocMiddleware extends MdcLoggable { // Build initial CallContext from request OptionT.liftF(Http4sCallContextBuilder.fromRequest(req, apiVersionFromPath)).flatMap { cc => ResourceDocMatcher.findResourceDoc(req.method.name, req.uri.path, resourceDocIndex) match { + case Some(resourceDoc) if !endpointIsEnabled(resourceDoc) => + // Disabled by api_disabled_endpoints / api_enabled_endpoints / api_disabled_versions / + // api_enabled_versions. Fall through so the Lift bridge can serve or 404. + OptionT.none[IO, Response[IO]] case Some(resourceDoc) => val ccWithDoc = ResourceDocMatcher.attachToCallContext(cc, resourceDoc) val pathParams = ResourceDocMatcher.extractPathParams(req.uri.path, resourceDoc) diff --git a/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisablePropsTest.scala b/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisablePropsTest.scala new file mode 100644 index 0000000000..7f781783ef --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisablePropsTest.scala @@ -0,0 +1,153 @@ +package code.api.util.http4s + +import cats.effect.IO +import cats.effect.unsafe.IORuntime +import code.api.v7_0_0.Http4s700 +import code.setup.ServerSetup +import fs2.Stream +import org.http4s.{Headers, Method, Request, Uri} +import org.scalatest.{GivenWhenThen, Tag} + +/** + * Integration test for the enable/disable Props wiring inside `ResourceDocMiddleware`. + * + * Drives `Http4s700.wrappedRoutesV700Services` in-process — no TCP, no DB. Verifies that + * setting the four Props (`api_disabled_endpoints`, `api_enabled_endpoints`, + * `api_disabled_versions`, `api_enabled_versions`) actually changes routing behaviour at + * request time. + * + * Why a separate test class from `ResourceDocMiddlewareEnableDisableTest`: + * That test pins the pure decision logic (`isEndpointEnabled`). This one pins the + * wiring — that the middleware actually reads the Props on each request and + * short-circuits to `OptionT.none` when the decision says disabled. With the routes + * driven via `.orNotFound`, a short-circuited request surfaces as 404. + * + * Why this works despite the Props being read inside the Kleisli: + * `PropsReset.setPropsValues` writes to Lift's locked-providers list at runtime. The + * middleware reads `APIUtil.getDisabledEndpointOperationIds()` etc. on every request, + * so changes made by `setPropsValues` in `beforeEach` are visible to the next request. + * `PropsReset.afterEach` restores the original providers so tests don't leak Props. + * + * The endpoint we use is `GET /obp/v7.0.0/root` — no auth, no DB, returns 200 on the + * happy path. This isolates routing from every other concern. + */ +class ResourceDocMiddlewareEnableDisablePropsTest extends ServerSetup with GivenWhenThen { + + object EnableDisablePropsTag extends Tag("EnableDisableProps") + + implicit val runtime: IORuntime = IORuntime.global + private val app = Http4s700.wrappedRoutesV700Services.orNotFound + + // OperationIds match `APIUtil.buildOperationId(v, partialFunctionName)` → + // s"$fullyQualifiedVersion-$name". v7.0.0's fully qualified form is "OBPv7.0.0". + private val rootOpId = "OBPv7.0.0-root" + private val getBanksOpId = "OBPv7.0.0-getBanks" + + private val rootPath = "/obp/v7.0.0/root" + private val banksPath = "/obp/v7.0.0/banks" + + private def get(path: String): Int = { + val req = Request[IO](Method.GET, Uri.unsafeFromString(path), headers = Headers.empty, + body = Stream.empty) + app.run(req).unsafeRunSync().status.code + } + + feature("ResourceDocMiddleware — Props wiring at request time") { + + scenario("Baseline: no Props set → /root returns 200", EnableDisablePropsTag) { + Given("no enable/disable Props are set") + When("requesting GET /obp/v7.0.0/root") + val status = get(rootPath) + Then("the endpoint serves normally") + status shouldBe 200 + } + + scenario("api_disabled_endpoints contains the operationId → 404", EnableDisablePropsTag) { + Given(s"api_disabled_endpoints=[$rootOpId]") + setPropsValues("api_disabled_endpoints" -> s"[$rootOpId]") + + When("requesting GET /obp/v7.0.0/root") + val status = get(rootPath) + + Then("the middleware short-circuits to OptionT.none → 404 via orNotFound") + status shouldBe 404 + + And("other endpoints in the same version are unaffected") + get(banksPath) shouldBe 200 + } + + scenario("api_enabled_endpoints contains a different operationId → 404 for non-listed", EnableDisablePropsTag) { + Given(s"api_enabled_endpoints=[$getBanksOpId] (root is NOT listed)") + setPropsValues("api_enabled_endpoints" -> s"[$getBanksOpId]") + + When("requesting GET /obp/v7.0.0/root") + val rootStatus = get(rootPath) + + Then("the middleware short-circuits to 404 — allowlist excludes root") + rootStatus shouldBe 404 + + And("the explicitly enabled endpoint still serves") + get(banksPath) shouldBe 200 + } + + scenario("api_enabled_endpoints contains the operationId → endpoint serves", EnableDisablePropsTag) { + Given(s"api_enabled_endpoints=[$rootOpId]") + setPropsValues("api_enabled_endpoints" -> s"[$rootOpId]") + + When("requesting GET /obp/v7.0.0/root") + val status = get(rootPath) + + Then("the endpoint serves normally") + status shouldBe 200 + } + + scenario("api_disabled_versions disables every endpoint of that version", EnableDisablePropsTag) { + Given("api_disabled_versions=[v7.0.0]") + setPropsValues("api_disabled_versions" -> "[v7.0.0]") + + When("requesting two unrelated v7 endpoints") + val rootStatus = get(rootPath) + val banksStatus = get(banksPath) + + Then("both are short-circuited by the middleware → 404") + rootStatus shouldBe 404 + banksStatus shouldBe 404 + } + + scenario("Disabled-endpoint wins over enabled-endpoint when same id is in both", EnableDisablePropsTag) { + Given(s"api_disabled_endpoints=[$rootOpId] AND api_enabled_endpoints=[$rootOpId]") + setPropsValues( + "api_disabled_endpoints" -> s"[$rootOpId]", + "api_enabled_endpoints" -> s"[$rootOpId]" + ) + + When("requesting GET /obp/v7.0.0/root") + val status = get(rootPath) + + Then("the disabled list wins → 404") + status shouldBe 404 + } + + scenario("api_disabled_versions overrides an explicit api_enabled_endpoints entry", EnableDisablePropsTag) { + Given(s"api_disabled_versions=[v7.0.0] AND api_enabled_endpoints=[$rootOpId]") + setPropsValues( + "api_disabled_versions" -> "[v7.0.0]", + "api_enabled_endpoints" -> s"[$rootOpId]" + ) + + When("requesting GET /obp/v7.0.0/root") + val status = get(rootPath) + + Then("the version gate wins → 404") + status shouldBe 404 + } + + scenario("After Props reset, baseline behavior is restored", EnableDisablePropsTag) { + Given("no Props set (afterEach in the prior scenario has reset locked providers)") + When("requesting GET /obp/v7.0.0/root") + val status = get(rootPath) + Then("the endpoint serves normally — proves PropsReset isolated each scenario") + status shouldBe 200 + } + } +} diff --git a/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisableTest.scala b/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisableTest.scala new file mode 100644 index 0000000000..f20ab6ca5d --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisableTest.scala @@ -0,0 +1,184 @@ +package code.api.util.http4s + +import code.api.util.APIUtil.ResourceDoc +import code.api.util.ApiTag.ResourceDocTag +import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} +import net.liftweb.json.JsonAST.JObject +import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers, Tag} + +/** + * Unit tests for `ResourceDocMiddleware.isEndpointEnabled`. + * + * Covers the gating logic the middleware applies on every request, matching the + * semantics of `APIUtil.getAllowedResourceDocs` / `APIUtil.versionIsAllowed` used + * on the Lift path: + * + * - `api_disabled_endpoints` — operationIds blocked by this Prop + * - `api_enabled_endpoints` — allowlist; if non-empty, only listed operationIds pass + * - `api_disabled_versions` / `api_enabled_versions` — modelled here as a + * `ScannedApiVersion => Boolean` so the decision can be tested without + * mutating global Props. + * + * Integration of the four real Props (`APIUtil.getDisabledEndpointOperationIds` + * etc.) into the middleware happens once at middleware construction; that wiring + * is verified by compile-time type-checking of `apply` plus the existing pure- + * function tests in `APIUtilHeavyTest`. This file pins the *composition* logic. + */ +class ResourceDocMiddlewareEnableDisableTest extends FeatureSpec with Matchers with GivenWhenThen { + + object EnableDisableTag extends Tag("EnableDisable") + + private def doc(operationName: String, version: ScannedApiVersion = ApiVersion.v7_0_0): ResourceDoc = + ResourceDoc( + partialFunction = null, + implementedInApiVersion = version, + partialFunctionName = operationName, + requestVerb = "GET", + requestUrl = "/test", + summary = "Test endpoint", + description = "Test description", + exampleRequestBody = JObject(Nil), + successResponseBody = JObject(Nil), + errorResponseBodies = List.empty, + tags = List(ResourceDocTag("test")), + roles = None + ) + + private val allowAllVersions: ScannedApiVersion => Boolean = _ => true + private val denyAllVersions: ScannedApiVersion => Boolean = _ => false + + feature("ResourceDocMiddleware.isEndpointEnabled — endpoint-level gating") { + + scenario("baseline: no Props set → endpoint is enabled", EnableDisableTag) { + Given("a ResourceDoc and empty disabled/enabled sets") + val rd = doc("getBank") + + When("isEndpointEnabled is called") + val result = ResourceDocMiddleware.isEndpointEnabled( + rd, disabledOperationIds = Set.empty, enabledOperationIds = Set.empty, allowAllVersions + ) + + Then("the endpoint is enabled") + result shouldBe true + } + + scenario("operationId in api_disabled_endpoints → disabled", EnableDisableTag) { + Given("a ResourceDoc whose operationId is listed in disabled") + val rd = doc("getBank") + + When("isEndpointEnabled is called with that operationId disabled") + val result = ResourceDocMiddleware.isEndpointEnabled( + rd, disabledOperationIds = Set(rd.operationId), enabledOperationIds = Set.empty, allowAllVersions + ) + + Then("the endpoint is disabled") + result shouldBe false + } + + scenario("api_enabled_endpoints is non-empty and excludes this operationId → disabled", EnableDisableTag) { + Given("an enabled allowlist that does not contain this operationId") + val rd = doc("getBank") + val other = doc("getBanks") + + When("isEndpointEnabled is called against the allowlist") + val result = ResourceDocMiddleware.isEndpointEnabled( + rd, disabledOperationIds = Set.empty, enabledOperationIds = Set(other.operationId), allowAllVersions + ) + + Then("the endpoint is disabled (allowlist excludes it)") + result shouldBe false + } + + scenario("api_enabled_endpoints contains this operationId → enabled", EnableDisableTag) { + Given("an enabled allowlist that contains this operationId") + val rd = doc("getBank") + + When("isEndpointEnabled is called against the allowlist") + val result = ResourceDocMiddleware.isEndpointEnabled( + rd, disabledOperationIds = Set.empty, enabledOperationIds = Set(rd.operationId), allowAllVersions + ) + + Then("the endpoint is enabled") + result shouldBe true + } + + scenario("empty api_enabled_endpoints does NOT disable anything (allow-all semantics)", EnableDisableTag) { + Given("an empty enabled allowlist") + val rd = doc("getBank") + + When("isEndpointEnabled is called") + val result = ResourceDocMiddleware.isEndpointEnabled( + rd, disabledOperationIds = Set.empty, enabledOperationIds = Set.empty, allowAllVersions + ) + + Then("the endpoint is enabled — empty allowlist means 'no restriction'") + result shouldBe true + } + + scenario("disabled wins over enabled — operationId in both sets → disabled", EnableDisableTag) { + Given("an operationId that is both enabled and disabled") + val rd = doc("getBank") + + When("isEndpointEnabled is called with the operationId in both sets") + val result = ResourceDocMiddleware.isEndpointEnabled( + rd, + disabledOperationIds = Set(rd.operationId), + enabledOperationIds = Set(rd.operationId), + allowAllVersions + ) + + Then("disabled wins") + result shouldBe false + } + } + + feature("ResourceDocMiddleware.isEndpointEnabled — version-level gating") { + + scenario("version is disabled → endpoint is disabled regardless of endpoint Props", EnableDisableTag) { + Given("a ResourceDoc whose version is denied") + val rd = doc("getBank", version = ApiVersion.v7_0_0) + + When("isEndpointEnabled is called with versionAllowed returning false") + val result = ResourceDocMiddleware.isEndpointEnabled( + rd, disabledOperationIds = Set.empty, enabledOperationIds = Set.empty, denyAllVersions + ) + + Then("the endpoint is disabled") + result shouldBe false + } + + scenario("version is disabled overrides an explicit enabled-endpoints entry → disabled", EnableDisableTag) { + Given("a ResourceDoc explicitly enabled by operationId but on a disabled version") + val rd = doc("getBank", version = ApiVersion.v7_0_0) + + When("isEndpointEnabled is called with the version denied") + val result = ResourceDocMiddleware.isEndpointEnabled( + rd, + disabledOperationIds = Set.empty, + enabledOperationIds = Set(rd.operationId), + denyAllVersions + ) + + Then("the version gate wins — endpoint is disabled") + result shouldBe false + } + + scenario("per-version gating: only the doc's own version matters", EnableDisableTag) { + Given("a versionAllowed function that allows v7 but denies v6") + val rd7 = doc("getBank", version = ApiVersion.v7_0_0) + val rd6 = doc("getBank", version = ApiVersion.v6_0_0) + val versionAllowed: ScannedApiVersion => Boolean = { + case v if v == ApiVersion.v7_0_0 => true + case _ => false + } + + When("isEndpointEnabled is called for each ResourceDoc") + val v7Result = ResourceDocMiddleware.isEndpointEnabled(rd7, Set.empty, Set.empty, versionAllowed) + val v6Result = ResourceDocMiddleware.isEndpointEnabled(rd6, Set.empty, Set.empty, versionAllowed) + + Then("v7 is enabled and v6 is disabled") + v7Result shouldBe true + v6Result shouldBe false + } + } +}