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
38 changes: 29 additions & 9 deletions obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -50,23 +52,41 @@ 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
*/
private def baseServices: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] =>
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))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down Expand Up @@ -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.
Expand All @@ -96,13 +118,28 @@ 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
}
// 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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")

Check failure on line 59 in obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisablePropsTest.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "requesting GET /obp/v7.0.0/root" 7 times.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AZ4Xg0VLBiUCh2OcXRtW&open=AZ4Xg0VLBiUCh2OcXRtW&pullRequest=2775
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
}
}
}
Loading
Loading