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.
This commit is contained in:
hongwei 2026-01-28 14:44:36 +01:00
parent 90ef73c863
commit d463b71c00
5 changed files with 290 additions and 3 deletions

View File

@ -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)
}

View File

@ -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)

View File

@ -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
}

View File

@ -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")
}
}
}
}

View File

@ -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")
}
}
}
}