From d463b71c006d72b0e4370f5fac363e69493d2b2e Mon Sep 17 00:00:00 2001 From: hongwei Date: Wed, 28 Jan 2026 14:44:36 +0100 Subject: [PATCH] feature/(api): add http4s support for v5.0.0 API routes Introduce new Http4s500 module providing v5.0.0 API endpoints alongside existing v7.0.0 routes. Update Http4sServer to serve both API versions by combining routes with fallback logic. Modify ResourceDocMiddleware to extract API version from request path for proper call context. Add comprehensive test suites for v5.0.0 routes and contract parity verification. --- .../scala/bootstrap/http4s/Http4sServer.scala | 8 +- .../util/http4s/ResourceDocMiddleware.scala | 6 +- .../scala/code/api/v5_0_0/Http4s500.scala | 116 ++++++++++++++++++ .../code/api/v5_0_0/Http4s500RoutesTest.scala | 74 +++++++++++ .../api/v5_0_0/V500ContractParityTest.scala | 89 ++++++++++++++ 5 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala create mode 100644 obp-api/src/test/scala/code/api/v5_0_0/Http4s500RoutesTest.scala create mode 100644 obp-api/src/test/scala/code/api/v5_0_0/V500ContractParityTest.scala diff --git a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala index 7a2a42c1c..7f6584d00 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala @@ -17,7 +17,12 @@ object Http4sServer extends IOApp { val port = APIUtil.getPropsAsIntValue("http4s.port",8086) val host = APIUtil.getPropsValue("http4s.host","127.0.0.1") - val services: HttpRoutes[IO] = code.api.v7_0_0.Http4s700.wrappedRoutesV700Services + type HttpF[A] = OptionT[IO, A] + + val services: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => + code.api.v5_0_0.Http4s500.wrappedRoutesV500Services.run(req) + .orElse(code.api.v7_0_0.Http4s700.wrappedRoutesV700Services.run(req)) + } val httpApp: Kleisli[IO, Request[IO], Response[IO]] = (services).orNotFound @@ -30,4 +35,3 @@ object Http4sServer extends IOApp { .use(_ => IO.never) .as(ExitCode.Success) } - 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 78e946fb0..65626716d 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 @@ -85,8 +85,12 @@ object ResourceDocMiddleware extends MdcLoggable { */ def apply(resourceDocs: ArrayBuffer[ResourceDoc]): HttpRoutes[IO] => HttpRoutes[IO] = { routes => Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => + val apiVersionFromPath = req.uri.path.segments.map(_.encoded).toList match { + case apiPathZero :: version :: _ if apiPathZero == APIUtil.getPropsValue("apiPathZero", "obp") => version + case _ => "v7.0.0" + } // Build initial CallContext from request - OptionT.liftF(Http4sCallContextBuilder.fromRequest(req, "v7.0.0")).flatMap { cc => + OptionT.liftF(Http4sCallContextBuilder.fromRequest(req, apiVersionFromPath)).flatMap { cc => ResourceDocMatcher.findResourceDoc(req.method.name, req.uri.path, resourceDocs) match { case Some(resourceDoc) => val ccWithDoc = ResourceDocMatcher.attachToCallContext(cc, resourceDoc) diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala new file mode 100644 index 000000000..671e4e244 --- /dev/null +++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala @@ -0,0 +1,116 @@ +package code.api.v5_0_0 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.Constant._ +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ +import code.api.util.APIUtil.{EmptyBody, ResourceDoc} +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.http4s.ResourceDocMiddleware +import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers +import code.api.util.{CustomJsonFormats, NewStyle} +import code.api.v4_0_0.JSONFactory400 +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} +import net.liftweb.json.JsonAST.prettyRender +import net.liftweb.json.{Extraction, Formats} +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.collection.mutable.ArrayBuffer +import scala.language.{higherKinds, implicitConversions} + +object Http4s500 { + + type HttpF[A] = OptionT[IO, A] + + implicit val formats: Formats = CustomJsonFormats.formats + implicit def convertAnyToJsonString(any: Any): String = prettyRender(Extraction.decompose(any)) + + val implementedInApiVersion: ScannedApiVersion = ApiVersion.v5_0_0 + val versionStatus: String = ApiVersionStatus.STABLE.toString + val resourceDocs: ArrayBuffer[ResourceDoc] = ArrayBuffer[ResourceDoc]() + + object Implementations5_0_0 { + + val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(root), + "GET", + "/root", + "Get API Info (root)", + """Returns information about: + | + |* API version + |* Hosted by information + |* Hosted at information + |* Energy source information + |* Git Commit""", + EmptyBody, + apiInfoJson400, + List( + UnknownError, + MandatoryPropertyIsNotSet + ), + apiTagApi :: Nil, + http4sPartialFunction = Some(root) + ) + + val root: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "root" => + val responseJson = convertAnyToJsonString( + JSONFactory400.getApiInfoJSON(OBPAPI5_0_0.version, OBPAPI5_0_0.versionStatus) + ) + Ok(responseJson) + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getBanks), + "GET", + "/banks", + "Get Banks", + """Get banks on this API instance + |Returns a list of banks supported on this server: + | + |* ID used as parameter in URLs + |* Short and full name of bank + |* Logo URL + |* Website""", + EmptyBody, + banksJSON, + List( + UnknownError + ), + apiTagBank :: Nil, + http4sPartialFunction = Some(getBanks) + ) + + val getBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + for { + (banks, callContext) <- NewStyle.function.getBanks(Some(cc)) + } yield JSONFactory400.createBanksJson(banks) + } + } + + val allRoutes: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => + root(req) + .orElse(getBanks(req)) + } + + val allRoutesWithMiddleware: HttpRoutes[IO] = + ResourceDocMiddleware.apply(resourceDocs)(allRoutes) + } + + val wrappedRoutesV500Services: HttpRoutes[IO] = Implementations5_0_0.allRoutesWithMiddleware +} + diff --git a/obp-api/src/test/scala/code/api/v5_0_0/Http4s500RoutesTest.scala b/obp-api/src/test/scala/code/api/v5_0_0/Http4s500RoutesTest.scala new file mode 100644 index 000000000..443966606 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v5_0_0/Http4s500RoutesTest.scala @@ -0,0 +1,74 @@ +package code.api.v5_0_0 + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import code.setup.ServerSetupWithTestData +import net.liftweb.json.JValue +import net.liftweb.json.JsonAST.{JArray, JField, JObject} +import net.liftweb.json.JsonParser.parse +import org.http4s.{Method, Request, Status, Uri} +import org.scalatest.Tag + +class Http4s500RoutesTest extends ServerSetupWithTestData { + + object Http4s500RoutesTag extends Tag("Http4s500Routes") + + private def runAndParseJson(request: Request[IO]): (Status, JValue) = { + val response = Http4s500.wrappedRoutesV500Services.orNotFound.run(request).unsafeRunSync() + val body = response.as[String].unsafeRunSync() + val json = if (body.trim.isEmpty) JObject(Nil) else parse(body) + (response.status, json) + } + + private def toFieldMap(fields: List[JField]): Map[String, JValue] = { + fields.map(field => field.name -> field.value).toMap + } + + feature("Http4s500 root endpoint") { + + scenario("Return API info JSON", Http4s500RoutesTag) { + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v5.0.0/root") + ) + + val (status, json) = runAndParseJson(request) + + status shouldBe Status.Ok + json match { + case JObject(fields) => + val keys = fields.map(_.name) + keys should contain("version") + keys should contain("version_status") + keys should contain("git_commit") + keys should contain("connector") + case _ => + fail("Expected JSON object for root endpoint") + } + } + } + + feature("Http4s500 banks endpoint") { + + scenario("Return banks list JSON", Http4s500RoutesTag) { + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v5.0.0/banks") + ) + + val (status, json) = runAndParseJson(request) + + status shouldBe Status.Ok + json match { + case JObject(fields) => + toFieldMap(fields).get("banks") match { + case Some(JArray(_)) => succeed + case _ => fail("Expected banks field to be an array") + } + case _ => + fail("Expected JSON object for banks endpoint") + } + } + } +} + diff --git a/obp-api/src/test/scala/code/api/v5_0_0/V500ContractParityTest.scala b/obp-api/src/test/scala/code/api/v5_0_0/V500ContractParityTest.scala new file mode 100644 index 000000000..16e3fc145 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v5_0_0/V500ContractParityTest.scala @@ -0,0 +1,89 @@ +package code.api.v5_0_0 + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import net.liftweb.json.JValue +import net.liftweb.json.JsonAST.{JArray, JField, JObject} +import net.liftweb.json.JsonParser.parse +import org.http4s.{Method, Request, Status, Uri} +import org.scalatest.Tag + +class V500ContractParityTest extends V500ServerSetup { + + object V500ContractParityTag extends Tag("V500ContractParity") + + private def http4sRunAndParseJson(path: String): (Status, JValue) = { + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString(path) + ) + val response = Http4s500.wrappedRoutesV500Services.orNotFound.run(request).unsafeRunSync() + val body = response.as[String].unsafeRunSync() + val json = if (body.trim.isEmpty) JObject(Nil) else parse(body) + (response.status, json) + } + + private def toFieldMap(fields: List[JField]): Map[String, JValue] = { + fields.map(field => field.name -> field.value).toMap + } + + feature("V500 Lift vs http4s parity") { + + scenario("root returns consistent status and key fields", V500ContractParityTag) { + val liftResponse = makeGetRequest((v5_0_0_Request / "root").GET) + val (http4sStatus, http4sJson) = http4sRunAndParseJson("/obp/v5.0.0/root") + + liftResponse.code should equal(http4sStatus.code) + + liftResponse.body match { + case JObject(fields) => + val keys = fields.map(_.name) + keys should contain("version") + keys should contain("version_status") + keys should contain("git_commit") + keys should contain("connector") + case _ => + fail("Expected Lift JSON object for root endpoint") + } + + http4sJson match { + case JObject(fields) => + val keys = fields.map(_.name) + keys should contain("version") + keys should contain("version_status") + keys should contain("git_commit") + keys should contain("connector") + case _ => + fail("Expected http4s JSON object for root endpoint") + } + } + + scenario("banks returns consistent status and banks array shape", V500ContractParityTag) { + val liftResponse = makeGetRequest((v5_0_0_Request / "banks").GET) + val (http4sStatus, http4sJson) = http4sRunAndParseJson("/obp/v5.0.0/banks") + + liftResponse.code should equal(http4sStatus.code) + + liftResponse.body match { + case JObject(fields) => + toFieldMap(fields).get("banks") match { + case Some(JArray(_)) => succeed + case _ => fail("Expected Lift banks field to be an array") + } + case _ => + fail("Expected Lift JSON object for banks endpoint") + } + + http4sJson match { + case JObject(fields) => + toFieldMap(fields).get("banks") match { + case Some(JArray(_)) => succeed + case _ => fail("Expected http4s banks field to be an array") + } + case _ => + fail("Expected http4s JSON object for banks endpoint") + } + } + } +} +