diff --git a/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala index 856b0f1ee..b7ffa5a94 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala @@ -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) + } } } 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 index a957601a4..0a64fbded 100644 --- 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 @@ -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] = 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 index 1832cdd7a..2afa76565 100644 --- 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 @@ -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") + } + } + } } 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 index 6265f36fd..65b39a043 100644 --- 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 @@ -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") + } + } } }