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