feature/(v5.0.0): add products endpoints to http4s routes

Implement GET /banks/{bankId}/products and GET /banks/{bankId}/products/{productCode} endpoints in the http4s v5.0.0 routes. Enhance error response converter to parse APIFailureNewStyle from exception messages. Add corresponding unit and contract parity tests.
This commit is contained in:
hongwei 2026-01-28 15:17:16 +01:00
parent cd34ffde55
commit 7c2e788d84
4 changed files with 198 additions and 3 deletions

View File

@ -5,6 +5,7 @@ import code.api.APIFailureNewStyle
import code.api.util.ErrorMessages._
import code.api.util.CallContext
import net.liftweb.common.{Failure => LiftFailure}
import net.liftweb.json.JsonParser.parse
import net.liftweb.json.compactRender
import net.liftweb.json.JsonDSL._
import org.http4s._
@ -30,6 +31,19 @@ object ErrorResponseConverter {
implicit val formats: Formats = CustomJsonFormats.formats
private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json)
private def tryExtractApiFailureFromExceptionMessage(error: Throwable): Option[APIFailureNewStyle] = {
val msg = Option(error.getMessage).getOrElse("").trim
if (msg.startsWith("{") && msg.contains("\"failCode\"") && msg.contains("\"failMsg\"")) {
try {
Some(parse(msg).extract[APIFailureNewStyle])
} catch {
case _: Throwable => None
}
} else {
None
}
}
/**
* OBP standard error response format.
@ -53,7 +67,11 @@ object ErrorResponseConverter {
def toHttp4sResponse(error: Throwable, callContext: CallContext): IO[Response[IO]] = {
error match {
case e: APIFailureNewStyle => apiFailureToResponse(e, callContext)
case _ => unknownErrorToResponse(error, callContext)
case _ =>
tryExtractApiFailureFromExceptionMessage(error) match {
case Some(apiFailure) => apiFailureToResponse(apiFailure, callContext)
case None => unknownErrorToResponse(error, callContext)
}
}
}

View File

@ -8,13 +8,16 @@ 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.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps}
import code.api.util.http4s.ErrorResponseConverter
import code.api.util.{CustomJsonFormats, NewStyle}
import code.api.util.APIUtil.getProductsIsPublic
import code.api.v4_0_0.JSONFactory400
import code.api.v5_0_0.JSONFactory500
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.ExecutionContext.Implicits.global
import com.openbankproject.commons.model.BankId
import com.openbankproject.commons.model.ProductCode
import com.openbankproject.commons.dto.GetProductsParam
import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion}
import net.liftweb.json.JsonAST.prettyRender
import net.liftweb.json.{Extraction, Formats}
@ -31,6 +34,19 @@ object Http4s500 {
implicit val formats: Formats = CustomJsonFormats.formats
implicit def convertAnyToJsonString(any: Any): String = prettyRender(Extraction.decompose(any))
private def okJson[A](a: A): IO[Response[IO]] = {
val jsonString = prettyRender(Extraction.decompose(a))
Ok(jsonString)
}
private def executeFuture[A](req: Request[IO])(f: => scala.concurrent.Future[A]): IO[Response[IO]] = {
implicit val cc: code.api.util.CallContext = req.callContext
IO.fromFuture(IO(f)).attempt.flatMap {
case Right(result) => okJson(result)
case Left(err) => ErrorResponseConverter.toHttp4sResponse(err, cc)
}
}
val implementedInApiVersion: ScannedApiVersion = ApiVersion.v5_0_0
val versionStatus: String = ApiVersionStatus.STABLE.toString
val resourceDocs: ArrayBuffer[ResourceDoc] = ArrayBuffer[ResourceDoc]()
@ -135,11 +151,81 @@ object Http4s500 {
}
}
private val productsAuthErrorBodies =
if (getProductsIsPublic) List(BankNotFound, UnknownError)
else List(AuthenticatedUserIsRequired, BankNotFound, UnknownError)
resourceDocs += ResourceDoc(
null,
implementedInApiVersion,
nameOf(getProducts),
"GET",
"/banks/BANK_ID/products",
"Get Products",
s"""Get products offered by the bank specified by BANK_ID.
|
|Can filter with attributes name and values.
|URL params example: /banks/some-bank-id/products?&limit=50&offset=1
|
|${code.api.util.APIUtil.userAuthenticationMessage(!getProductsIsPublic)}""".stripMargin,
EmptyBody,
productsJsonV400,
productsAuthErrorBodies,
List(apiTagProduct),
http4sPartialFunction = Some(getProducts)
)
val getProducts: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "banks" / bankId / "products" =>
executeFuture(req) {
val cc = req.callContext
val params = req.uri.query.multiParams.toList.map { case (k, vs) =>
GetProductsParam(k, vs.toList)
}
for {
(products, callContext) <- NewStyle.function.getProducts(BankId(bankId), params, Some(cc))
} yield JSONFactory400.createProductsJson(products)
}
}
resourceDocs += ResourceDoc(
null,
implementedInApiVersion,
nameOf(getProduct),
"GET",
"/banks/BANK_ID/products/PRODUCT_CODE",
"Get Bank Product",
s"""Returns information about a financial Product offered by the bank specified by BANK_ID and PRODUCT_CODE.
|
|${code.api.util.APIUtil.userAuthenticationMessage(!getProductsIsPublic)}""".stripMargin,
EmptyBody,
productJsonV400,
productsAuthErrorBodies ::: List(ProductNotFoundByProductCode),
List(apiTagProduct),
http4sPartialFunction = Some(getProduct)
)
val getProduct: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "banks" / bankId / "products" / productCode =>
executeFuture(req) {
val cc = req.callContext
val bankIdObj = BankId(bankId)
val productCodeObj = ProductCode(productCode)
for {
(product, callContext) <- NewStyle.function.getProduct(bankIdObj, productCodeObj, Some(cc))
(productAttributes, callContext) <- NewStyle.function.getProductAttributesByBankAndCode(bankIdObj, productCodeObj, callContext)
(productFees, callContext) <- NewStyle.function.getProductFeesFromProvider(bankIdObj, productCodeObj, callContext)
} yield JSONFactory400.createProductJson(product, productAttributes, productFees)
}
}
val allRoutes: HttpRoutes[IO] =
Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] =>
root(req)
.orElse(getBanks(req))
.orElse(getBank(req))
.orElse(getProducts(req))
.orElse(getProduct(req))
}
val allRoutesWithMiddleware: HttpRoutes[IO] =

View File

@ -93,4 +93,44 @@ class Http4s500RoutesTest extends ServerSetupWithTestData {
}
}
}
feature("Http4s500 products endpoints") {
scenario("Return products list JSON", Http4s500RoutesTag) {
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString(s"/obp/v5.0.0/banks/${APIUtil.defaultBankId}/products")
)
val (status, json) = runAndParseJson(request)
status shouldBe Status.Ok
json match {
case JObject(fields) =>
toFieldMap(fields).get("products") match {
case Some(JArray(_)) => succeed
case _ => fail("Expected products field to be an array")
}
case _ =>
fail("Expected JSON object for products endpoint")
}
}
scenario("Return 404 for missing product", Http4s500RoutesTag) {
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString(s"/obp/v5.0.0/banks/${APIUtil.defaultBankId}/products/DOES_NOT_EXIST")
)
val (status, json) = runAndParseJson(request)
status shouldBe Status.NotFound
json match {
case JObject(fields) =>
toFieldMap(fields).get("message") should not be empty
case _ =>
fail("Expected JSON object for error response")
}
}
}
}

View File

@ -107,5 +107,56 @@ class V500ContractParityTest extends V500ServerSetup {
getStringField(liftResponse.body, "id") shouldBe Some(bankId)
getStringField(http4sJson, "id") shouldBe Some(bankId)
}
scenario("products list returns consistent status and products array shape", V500ContractParityTag) {
val bankId = APIUtil.defaultBankId
val liftResponse = makeGetRequest((v5_0_0_Request / "banks" / bankId / "products").GET)
val (http4sStatus, http4sJson) = http4sRunAndParseJson(s"/obp/v5.0.0/banks/$bankId/products")
liftResponse.code should equal(http4sStatus.code)
liftResponse.body match {
case JObject(fields) =>
toFieldMap(fields).get("products") match {
case Some(JArray(_)) => succeed
case _ => fail("Expected Lift products field to be an array")
}
case _ =>
fail("Expected Lift JSON object for products endpoint")
}
http4sJson match {
case JObject(fields) =>
toFieldMap(fields).get("products") match {
case Some(JArray(_)) => succeed
case _ => fail("Expected http4s products field to be an array")
}
case _ =>
fail("Expected http4s JSON object for products endpoint")
}
}
scenario("product returns consistent 404 for missing product", V500ContractParityTag) {
val bankId = APIUtil.defaultBankId
val productCode = "DOES_NOT_EXIST"
val liftResponse = makeGetRequest((v5_0_0_Request / "banks" / bankId / "products" / productCode).GET)
val (http4sStatus, http4sJson) = http4sRunAndParseJson(s"/obp/v5.0.0/banks/$bankId/products/$productCode")
liftResponse.code should equal(http4sStatus.code)
liftResponse.body match {
case JObject(fields) =>
toFieldMap(fields).get("message") should not be empty
case _ =>
fail("Expected Lift JSON object for missing product error")
}
http4sJson match {
case JObject(fields) =>
toFieldMap(fields).get("message") should not be empty
case _ =>
fail("Expected http4s JSON object for missing product error")
}
}
}
}