From 4b0223ecb586a913efef34436804c754b5813afd Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 16 Oct 2025 09:29:05 +0200 Subject: [PATCH 01/20] feature/Add in-memory offer service and related models, implementing create, cancel, and get offer functionalities, and update API routes to support offer management. --- .../trading/http/HttpServerMain.scala | 5 +- .../openbankproject/trading/http/Routes.scala | 13 +-- .../openbankproject/trading/http/models.scala | 24 ++++++ .../service/InMemoryOfferService.scala | 84 +++++++++++++++++++ .../trading/service/Services.scala | 6 ++ 5 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 src/main/scala/com/openbankproject/trading/service/InMemoryOfferService.scala diff --git a/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala b/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala index 645a6ba..27002f2 100644 --- a/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala +++ b/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala @@ -14,6 +14,8 @@ import com.comcast.ip4s._ object HttpServerMain extends IOApp.Simple { private val orderServiceIO: IO[OrderService[IO]] = InMemoryOrderService.create[IO]() + + private val offerServiceIO: IO[OfferService[IO]] = InMemoryOfferService.create[IO]() private val matchService: MatchService[IO] = new MatchService[IO] { def createMatch(req: MatchRequest) = IO.pure(Left(ErrorResponse("not_implemented", "createMatch not implemented"))) @@ -31,8 +33,9 @@ object HttpServerMain extends IOApp.Simple { override def run: IO[Unit] = for { + offerService <- offerServiceIO orderService <- orderServiceIO - apiRoutes = Routes.api[IO](orderService, matchService, settlementService, fundsService) + apiRoutes = Routes.api[IO](orderService, offerService, matchService, settlementService, fundsService) httpApp = Router("/" -> apiRoutes).orNotFound _ <- EmberServerBuilder .default[IO] diff --git a/src/main/scala/com/openbankproject/trading/http/Routes.scala b/src/main/scala/com/openbankproject/trading/http/Routes.scala index b5c997f..2e8635a 100644 --- a/src/main/scala/com/openbankproject/trading/http/Routes.scala +++ b/src/main/scala/com/openbankproject/trading/http/Routes.scala @@ -15,6 +15,7 @@ import scala.util.Try object Routes { def api[F[_]: Async]( order: OrderService[F], + offer: OfferService[F], matcher: MatchService[F], settlement: SettlementService[F], funds: FundsService[F] @@ -68,7 +69,7 @@ object Routes { case req @ POST -> Root / "market" / "withdrawals" => Async[F].pure(Response[F](status = Status.NotImplemented)) - // ========== OBP-style Offer endpoints (map to OrderService) ========== + // ========== OBP-style Offer endpoints (map to OfferService) ========== // POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers case req @ POST -> Root / "obp" / "v7.0.0" / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" => // Minimal body mapping based on trading-api-endpoints.md @@ -95,14 +96,14 @@ object Routes { val price = Try(BigDecimal(obp.price_amount)).getOrElse(BigDecimal(0)) val acct = Option(obp.settlement_account_id).filter(_.nonEmpty).getOrElse(accountId) val idKey = s"obp-${UUID.randomUUID().toString}" - val req0 = CreateOrderRequest( - side = side, + val req0 = CreateOfferRequest( + offerType = side, price = price, quantity = qty, accountId = acct, idempotencyKey = idKey ) - order.createOrder(req0).flatMap { + offer.createOffer(req0).flatMap { case Right(ok) => Created(ok) case Left(err) => BadRequest(err) } @@ -111,14 +112,14 @@ object Routes { // GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID case GET -> Root / "obp" / "v7.0.0" / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / offerId => - order.getOrder(offerId).flatMap { + offer.getOffer(offerId).flatMap { case Right(v) => Ok(v) case Left(err) => NotFound(err) } // DELETE /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID case DELETE -> Root / "obp" / "v7.0.0" / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / offerId => - order.cancelOrder(offerId).flatMap { + offer.cancelOffer(offerId).flatMap { case Right(ok) => Ok(ok) case Left(err) => BadRequest(err) } diff --git a/src/main/scala/com/openbankproject/trading/http/models.scala b/src/main/scala/com/openbankproject/trading/http/models.scala index fef743f..23d4d30 100644 --- a/src/main/scala/com/openbankproject/trading/http/models.scala +++ b/src/main/scala/com/openbankproject/trading/http/models.scala @@ -12,6 +12,15 @@ final case class CreateOrderRequest( idempotencyKey: String ) +// Offer-specific request/response models +final case class CreateOfferRequest( + offerType: String, // "BUY" | "SELL" + price: BigDecimal, + quantity: BigDecimal, + accountId: String, + idempotencyKey: String +) + final case class MatchRequest( orderId: String, counterOrderId: String, @@ -47,6 +56,9 @@ final case class SettlementResponse(tradeId: String, status: String) final case class DepositResponse(credited: Boolean, externalId: String) final case class WithdrawalResponse(onChainTxId: String, state: String) +final case class CreateOfferResponse(offerId: String, status: String, remaining: BigDecimal) +final case class CancelOfferResponse(offerId: String, status: String) + // Views final case class OrderView( orderId: String, @@ -60,6 +72,18 @@ final case class OrderView( expiresAt: Option[Instant] ) +final case class OfferView( + offerId: String, + offerType: String, + price: BigDecimal, + quantity: BigDecimal, + remaining: BigDecimal, + status: String, + ownerAccountId: String, + createdAt: Instant, + expiresAt: Option[Instant] +) + final case class TradeView( tradeId: String, buyOrderId: String, diff --git a/src/main/scala/com/openbankproject/trading/service/InMemoryOfferService.scala b/src/main/scala/com/openbankproject/trading/service/InMemoryOfferService.scala new file mode 100644 index 0000000..6fa70d5 --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/service/InMemoryOfferService.scala @@ -0,0 +1,84 @@ +package com.openbankproject.trading.service + +import cats.effect.kernel.Async +import cats.effect.Ref +import cats.syntax.all._ +import com.openbankproject.trading.http._ + +import java.time.Instant +import java.util.UUID + +final class InMemoryOfferService[F[_]: Async] private ( + state: Ref[F, Map[String, InMemoryOfferService.StoredOffer]] +) extends OfferService[F] { + + import InMemoryOfferService.StoredOffer + + def createOffer(req: CreateOfferRequest): F[Either[ErrorResponse, CreateOfferResponse]] = { + val id = UUID.randomUUID().toString + val now = Instant.now() + val stored = StoredOffer( + offerId = id, + offerType = req.offerType, + price = req.price, + quantity = req.quantity, + remaining = req.quantity, + status = "active", + ownerAccountId = req.accountId, + createdAt = now, + expiresAt = None + ) + state.update(_ + (id -> stored)) *> Async[F].pure(Right(CreateOfferResponse(id, stored.status, stored.remaining))) + } + + def cancelOffer(offerId: String): F[Either[ErrorResponse, CancelOfferResponse]] = { + state.modify { m => + m.get(offerId) match { + case Some(o) => + val updated = o.copy(status = "cancelled") + (m.updated(offerId, updated), Right(CancelOfferResponse(offerId, updated.status))) + case None => + (m, Left(ErrorResponse("not_found", s"Offer $offerId not found"))) + } + } + } + + def getOffer(offerId: String): F[Either[ErrorResponse, OfferView]] = { + state.get.map { m => + m.get(offerId) match { + case Some(o) => + Right(OfferView( + offerId = o.offerId, + offerType = o.offerType, + price = o.price, + quantity = o.quantity, + remaining = o.remaining, + status = o.status, + ownerAccountId = o.ownerAccountId, + createdAt = o.createdAt, + expiresAt = o.expiresAt + )) + case None => Left(ErrorResponse("not_found", s"Offer $offerId not found")) + } + } + } +} + +object InMemoryOfferService { + private final case class StoredOffer( + offerId: String, + offerType: String, + price: BigDecimal, + quantity: BigDecimal, + remaining: BigDecimal, + status: String, + ownerAccountId: String, + createdAt: Instant, + expiresAt: Option[Instant] + ) + + def create[F[_]: Async](): F[InMemoryOfferService[F]] = + Ref.of[F, Map[String, StoredOffer]](Map.empty).map(ref => new InMemoryOfferService[F](ref)) +} + + diff --git a/src/main/scala/com/openbankproject/trading/service/Services.scala b/src/main/scala/com/openbankproject/trading/service/Services.scala index 402a4b7..e987162 100644 --- a/src/main/scala/com/openbankproject/trading/service/Services.scala +++ b/src/main/scala/com/openbankproject/trading/service/Services.scala @@ -8,6 +8,12 @@ trait OrderService[F[_]] { def getOrder(orderId: String): F[Either[ErrorResponse, OrderView]] } +trait OfferService[F[_]] { + def createOffer(req: CreateOfferRequest): F[Either[ErrorResponse, CreateOfferResponse]] + def cancelOffer(offerId: String): F[Either[ErrorResponse, CancelOfferResponse]] + def getOffer(offerId: String): F[Either[ErrorResponse, OfferView]] +} + trait MatchService[F[_]] { def createMatch(req: MatchRequest): F[Either[ErrorResponse, CreateMatchResponse]] } From 6018a9deb279b98bd7aa33a25dea067e0b3d2f86 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 16 Oct 2025 09:31:30 +0200 Subject: [PATCH 02/20] refactor/Update .gitignore to simplify path for metals.sbt, improving clarity in project structure --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index ec8dc9f..eabc8ce 100644 --- a/.gitignore +++ b/.gitignore @@ -139,6 +139,5 @@ build/ *.code-workspace .specstory -project/project/metals.sbt -project/metals.sbt +metals.sbt .bsp/sbt.json From e500b51d6d5bdfc6c92a2cba3fc69639d30a656b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 16 Oct 2025 10:09:42 +0200 Subject: [PATCH 03/20] test/Add unit tests for OfferRoutes, covering create, retrieve, and cancel offer scenarios, as well as handling invalid JSON and non-existing offers, enhancing test coverage for offer management functionality. --- .../trading/http/OfferRoutesTest.scala | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/test/scala/com/openbankproject/trading/http/OfferRoutesTest.scala diff --git a/src/test/scala/com/openbankproject/trading/http/OfferRoutesTest.scala b/src/test/scala/com/openbankproject/trading/http/OfferRoutesTest.scala new file mode 100644 index 0000000..6653664 --- /dev/null +++ b/src/test/scala/com/openbankproject/trading/http/OfferRoutesTest.scala @@ -0,0 +1,113 @@ +package com.openbankproject.trading.http + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import com.openbankproject.trading.service._ +import io.circe.syntax._ +import io.circe.generic.auto._ +import org.http4s._ +import org.http4s.Method._ +import org.http4s.circe.CirceEntityCodec._ +import org.http4s.implicits._ +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class OfferRoutesTest extends AnyFunSuite with Matchers { + + private def app(offerService: OfferService[IO]) = { + val orderStub: OrderService[IO] = new OrderService[IO] { + def createOrder(req: CreateOrderRequest) = IO.pure(Left(ErrorResponse("not_implemented", "order create not used in this test"))) + def cancelOrder(orderId: String) = IO.pure(Left(ErrorResponse("not_implemented", "order cancel not used in this test"))) + def getOrder(orderId: String) = IO.pure(Left(ErrorResponse("not_found", s"order $orderId not found"))) + } + val matchStub: MatchService[IO] = new MatchService[IO] { + def createMatch(req: MatchRequest) = IO.pure(Left(ErrorResponse("not_implemented", "match not used in this test"))) + } + val settlementStub: SettlementService[IO] = new SettlementService[IO] { + def settle(req: SettlementRequest) = IO.pure(Left(ErrorResponse("not_implemented", "settle not used in this test"))) + def getTrade(tradeId: String) = IO.pure(Left(ErrorResponse("not_implemented", "getTrade not used in this test"))) + } + val fundsStub: FundsService[IO] = new FundsService[IO] { + def notifyDeposit(req: DepositNotification) = IO.pure(Left(ErrorResponse("not_implemented", "notifyDeposit not used in this test"))) + def requestWithdrawal(req: WithdrawalRequest) = IO.pure(Left(ErrorResponse("not_implemented", "requestWithdrawal not used in this test"))) + } + Routes.api[IO](orderStub, offerService, matchStub, settlementStub, fundsStub).orNotFound + } + + test("create -> get -> cancel offer happy path") { + val service = InMemoryOfferService.create[IO]().unsafeRunSync() + val httpApp = app(service) + + val bankId = "bank-1" + val accountId = "acc-1" + val viewId = "owner" + + val createJson = Map( + "offer_type" -> "BUY", + "asset_code" -> "BTC", + "asset_amount" -> "1.5", + "price_currency" -> "USD", + "price_amount" -> "45000.00", + "settlement_account_id" -> accountId + ).asJson + + val createReq = Request[IO]( + method = POST, + uri = uri"/obp/v7.0.0/banks/" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" + ).withEntity(createJson) + + val createResp = httpApp.run(createReq).unsafeRunSync() + createResp.status shouldBe Status.Created + val created = createResp.as[CreateOfferResponse].unsafeRunSync() + created.status shouldBe "active" + created.remaining shouldBe BigDecimal("1.5") + + val getReq = Request[IO]( + method = GET, + uri = uri"/obp/v7.0.0/banks/" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / created.offerId + ) + val getResp = httpApp.run(getReq).unsafeRunSync() + getResp.status shouldBe Status.Ok + val view = getResp.as[OfferView].unsafeRunSync() + view.offerId shouldBe created.offerId + view.offerType shouldBe "BUY" + + val delReq = Request[IO]( + method = DELETE, + uri = uri"/obp/v7.0.0/banks/" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / created.offerId + ) + val delResp = httpApp.run(delReq).unsafeRunSync() + delResp.status shouldBe Status.Ok + val cancelled = delResp.as[CancelOfferResponse].unsafeRunSync() + cancelled.offerId shouldBe created.offerId + cancelled.status shouldBe "cancelled" + } + + test("invalid JSON returns 400") { + val service = InMemoryOfferService.create[IO]().unsafeRunSync() + val httpApp = app(service) + + val req = Request[IO]( + method = POST, + uri = uri"/obp/v7.0.0/banks/b1/accounts/a1/views/v1/trading/offers" + ).withEntity("{" ) // malformed JSON + + val resp = httpApp.run(req).unsafeRunSync() + resp.status shouldBe Status.BadRequest + } + + test("get non-existing offer returns 404") { + val service = InMemoryOfferService.create[IO]().unsafeRunSync() + val httpApp = app(service) + + val req = Request[IO]( + method = GET, + uri = uri"/obp/v7.0.0/banks/b1/accounts/a1/views/v1/trading/offers/does-not-exist" + ) + + val resp = httpApp.run(req).unsafeRunSync() + resp.status shouldBe Status.NotFound + } +} + + From 34b36cde7d5b67fbae5b3bd93c8bf942945414ed Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 27 Nov 2025 12:13:31 +0100 Subject: [PATCH 04/20] feature/Add initial implementation of trading documentation models and registry, including ResourceDoc, ErrorDoc, HttpMethod, RequiredRole, and ConsistencyCheck for managing API endpoint documentation. --- .../trading/docs/TradingResourceDocs.scala | 16 ++++++++++ .../trading/docs/model/ErrorDoc.scala | 5 +++ .../trading/docs/model/HttpMethod.scala | 15 +++++++++ .../trading/docs/model/RequiredRole.scala | 11 +++++++ .../trading/docs/model/ResourceDoc.scala | 19 +++++++++++ .../docs/registry/ConsistencyCheck.scala | 10 ++++++ .../docs/registry/ResourceDocRegistry.scala | 23 +++++++++++++ .../openbankproject/trading/http/Routes.scala | 32 +++++++++++++++++++ 8 files changed, 131 insertions(+) create mode 100644 src/main/scala/com/openbankproject/trading/docs/TradingResourceDocs.scala create mode 100644 src/main/scala/com/openbankproject/trading/docs/model/ErrorDoc.scala create mode 100644 src/main/scala/com/openbankproject/trading/docs/model/HttpMethod.scala create mode 100644 src/main/scala/com/openbankproject/trading/docs/model/RequiredRole.scala create mode 100644 src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala create mode 100644 src/main/scala/com/openbankproject/trading/docs/registry/ConsistencyCheck.scala create mode 100644 src/main/scala/com/openbankproject/trading/docs/registry/ResourceDocRegistry.scala diff --git a/src/main/scala/com/openbankproject/trading/docs/TradingResourceDocs.scala b/src/main/scala/com/openbankproject/trading/docs/TradingResourceDocs.scala new file mode 100644 index 0000000..1ee3a7e --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/docs/TradingResourceDocs.scala @@ -0,0 +1,16 @@ +package com.openbankproject.trading.docs + +import com.openbankproject.trading.docs.model.{ErrorDoc, HttpMethod, RequiredRole, ResourceDoc} +import com.openbankproject.trading.docs.registry.ResourceDocRegistry + +/** + * Registers ResourceDocs for OBP-Trading endpoints (framework-agnostic). + */ +object TradingResourceDocs { + + private val docs = Seq.empty[ResourceDoc] + + def registerAll(): Unit = ResourceDocRegistry.registerAll(docs) +} + + diff --git a/src/main/scala/com/openbankproject/trading/docs/model/ErrorDoc.scala b/src/main/scala/com/openbankproject/trading/docs/model/ErrorDoc.scala new file mode 100644 index 0000000..0ac8506 --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/docs/model/ErrorDoc.scala @@ -0,0 +1,5 @@ +package com.openbankproject.trading.docs.model + +final case class ErrorDoc(code: String, httpStatus: Int, message: Option[String] = None) + + diff --git a/src/main/scala/com/openbankproject/trading/docs/model/HttpMethod.scala b/src/main/scala/com/openbankproject/trading/docs/model/HttpMethod.scala new file mode 100644 index 0000000..5569676 --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/docs/model/HttpMethod.scala @@ -0,0 +1,15 @@ +package com.openbankproject.trading.docs.model + +sealed trait HttpMethod extends Product with Serializable { def name: String } + +object HttpMethod { + case object GET extends HttpMethod { val name: String = "GET" } + case object POST extends HttpMethod { val name: String = "POST" } + case object PUT extends HttpMethod { val name: String = "PUT" } + case object DELETE extends HttpMethod { val name: String = "DELETE" } + case object PATCH extends HttpMethod { val name: String = "PATCH" } + case object HEAD extends HttpMethod { val name: String = "HEAD" } + case object OPTIONS extends HttpMethod { val name: String = "OPTIONS" } +} + + diff --git a/src/main/scala/com/openbankproject/trading/docs/model/RequiredRole.scala b/src/main/scala/com/openbankproject/trading/docs/model/RequiredRole.scala new file mode 100644 index 0000000..06a7782 --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/docs/model/RequiredRole.scala @@ -0,0 +1,11 @@ +package com.openbankproject.trading.docs.model + +sealed trait RequiredRole extends Product with Serializable + +object RequiredRole { + case object Public extends RequiredRole { + override def toString: String = "Public" + } +} + + diff --git a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala new file mode 100644 index 0000000..867bfcb --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala @@ -0,0 +1,19 @@ +package com.openbankproject.trading.docs.model + +final case class ResourceDoc( + operationId: String, + method: HttpMethod, + path: String, + summary: String, + description: String, + roles: RequiredRole = RequiredRole.Public, + tags: Set[String] = Set.empty, + requestExample: Option[String] = None, + responseExample: Option[String] = None, + errorResponses: List[ErrorDoc] = Nil +) { + require(operationId.trim.nonEmpty, "operationId must be non-empty") + require(path.trim.nonEmpty, "path must be non-empty") +} + + diff --git a/src/main/scala/com/openbankproject/trading/docs/registry/ConsistencyCheck.scala b/src/main/scala/com/openbankproject/trading/docs/registry/ConsistencyCheck.scala new file mode 100644 index 0000000..43a86e2 --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/docs/registry/ConsistencyCheck.scala @@ -0,0 +1,10 @@ +package com.openbankproject.trading.docs.registry + +import com.openbankproject.trading.docs.model.ResourceDoc + +object ConsistencyCheck { + def findDuplicateOperationIds(docs: Seq[ResourceDoc]): Map[String, Int] = + docs.groupBy(_.operationId).view.mapValues(_.size).filter(_._2 > 1).toMap +} + + diff --git a/src/main/scala/com/openbankproject/trading/docs/registry/ResourceDocRegistry.scala b/src/main/scala/com/openbankproject/trading/docs/registry/ResourceDocRegistry.scala new file mode 100644 index 0000000..6734f9d --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/docs/registry/ResourceDocRegistry.scala @@ -0,0 +1,23 @@ +package com.openbankproject.trading.docs.registry + +import com.openbankproject.trading.docs.model.ResourceDoc +import scala.collection.concurrent.TrieMap + +object ResourceDocRegistry { + private[this] val byOperationId: TrieMap[String, ResourceDoc] = TrieMap.empty + + def register(doc: ResourceDoc): Unit = { + byOperationId.put(doc.operationId, doc) + () + } + + def registerAll(docs: Iterable[ResourceDoc]): Unit = docs.foreach(register) + + def get(operationId: String): Option[ResourceDoc] = byOperationId.get(operationId) + + def all: Vector[ResourceDoc] = byOperationId.values.toVector.sortBy(_.operationId) + + def clear(): Unit = byOperationId.clear() +} + + diff --git a/src/main/scala/com/openbankproject/trading/http/Routes.scala b/src/main/scala/com/openbankproject/trading/http/Routes.scala index 2e8635a..487b4b7 100644 --- a/src/main/scala/com/openbankproject/trading/http/Routes.scala +++ b/src/main/scala/com/openbankproject/trading/http/Routes.scala @@ -10,6 +10,8 @@ import io.circe.generic.auto._ import io.circe.parser.parse import java.util.UUID import scala.util.Try +import com.openbankproject.trading.docs.model.{ResourceDoc, HttpMethod, RequiredRole, ErrorDoc} +import com.openbankproject.trading.docs.registry.ResourceDocRegistry /** Aggregated HTTP routes (interfaces only, no concrete wiring). */ object Routes { @@ -22,6 +24,36 @@ object Routes { ): HttpRoutes[F] = { val dsl = new Http4sDsl[F] {}; import dsl._ + // ResourceDoc for: GET /obp/v7.0.0/.../trading/offers/{OFFER_ID} + val getObpOfferDoc: ResourceDoc = ResourceDoc( + operationId = "getObpOffer", + method = HttpMethod.GET, + path = "/obp/v7.0.0/banks/{BANK_ID}/accounts/{ACCOUNT_ID}/views/{VIEW_ID}/trading/offers/{OFFER_ID}", + summary = "Get OBP trading offer by id", + description = "Returns the trading offer details by id for the given bank/account/view.", + roles = RequiredRole.Public, + tags = Set("trading", "offer"), + requestExample = None, + responseExample = Some( + """{ + | "offerId": "OFFER-123", + | "offerType": "BUY", + | "price": 100.50, + | "quantity": 2.0, + | "remaining": 0.0, + | "status": "FILLED", + | "ownerAccountId": "ACC-001", + | "createdAt": "2025-11-03T10:20:30Z", + | "expiresAt": null + |}""".stripMargin + ), + errorResponses = List( + ErrorDoc(code = "not_found", httpStatus = 404, message = Some("Offer not found")), + ErrorDoc(code = "bad_request", httpStatus = 400, message = Some("Invalid parameters")) + ) + ) + ResourceDocRegistry.register(getObpOfferDoc) + HttpRoutes.of[F] { // ========== Minimal market endpoints (internal shape) ========== // POST /market/orders From d1a9b76778e6718c824ab00c9d906ebf2ee70769 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 28 Nov 2025 09:37:28 +0100 Subject: [PATCH 05/20] feature/Add server configuration to application.conf.example and update HttpServerMain to use dynamic host and port settings from configuration --- .gitignore | 1 + src/main/resources/application.conf.example | 5 +++++ .../openbankproject/trading/http/HttpServerMain.scala | 10 ++++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index eabc8ce..8db5b53 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,4 @@ build/ .specstory metals.sbt .bsp/sbt.json +*.conf diff --git a/src/main/resources/application.conf.example b/src/main/resources/application.conf.example index e0c2d13..2f72349 100644 --- a/src/main/resources/application.conf.example +++ b/src/main/resources/application.conf.example @@ -1,6 +1,11 @@ // Example configuration for OBP-Trading // Copy to application.conf and adjust values for your environment +server { + host = "0.0.0.0" // HTTP server host + port = "8086" // HTTP server port +} + obp { api { base_url = "https://api.openbankproject.com" // OBP-API base URL diff --git a/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala b/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala index 27002f2..99cab18 100644 --- a/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala +++ b/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala @@ -6,6 +6,7 @@ import org.http4s.server.Router import org.http4s.implicits._ import org.http4s.ember.server.EmberServerBuilder import com.comcast.ip4s._ +import com.typesafe.config.ConfigFactory /** * Minimal http4s server that mounts Routes.api with placeholder services. @@ -33,14 +34,19 @@ object HttpServerMain extends IOApp.Simple { override def run: IO[Unit] = for { + config <- IO(ConfigFactory.load()) + serverHost = config.getString("server.host") + serverPort = config.getString("server.port").toInt offerService <- offerServiceIO orderService <- orderServiceIO apiRoutes = Routes.api[IO](orderService, offerService, matchService, settlementService, fundsService) httpApp = Router("/" -> apiRoutes).orNotFound + host <- IO.fromOption(Host.fromString(serverHost))(new IllegalArgumentException(s"Invalid host: $serverHost")) + port <- IO.fromOption(Port.fromInt(serverPort))(new IllegalArgumentException(s"Invalid port: $serverPort")) _ <- EmberServerBuilder .default[IO] - .withHost(ipv4"0.0.0.0") - .withPort(port"8080") + .withHost(host) + .withPort(port) .withHttpApp(httpApp) .build .useForever From ecefddd9527002c9e124342b4c50a772d840da1d Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 28 Nov 2025 10:09:41 +0100 Subject: [PATCH 06/20] feature/Add HTTP package object with constants, type aliases, and utility functions; implement new OBP and market endpoint partial functions in Routes for offer and order management. --- .../openbankproject/trading/http/Routes.scala | 228 ++++++++++++------ .../trading/http/package.scala | 53 ++++ 2 files changed, 201 insertions(+), 80 deletions(-) create mode 100644 src/main/scala/com/openbankproject/trading/http/package.scala diff --git a/src/main/scala/com/openbankproject/trading/http/Routes.scala b/src/main/scala/com/openbankproject/trading/http/Routes.scala index 487b4b7..ddb208e 100644 --- a/src/main/scala/com/openbankproject/trading/http/Routes.scala +++ b/src/main/scala/com/openbankproject/trading/http/Routes.scala @@ -15,47 +15,56 @@ import com.openbankproject.trading.docs.registry.ResourceDocRegistry /** Aggregated HTTP routes (interfaces only, no concrete wiring). */ object Routes { - def api[F[_]: Async]( - order: OrderService[F], - offer: OfferService[F], - matcher: MatchService[F], - settlement: SettlementService[F], - funds: FundsService[F] - ): HttpRoutes[F] = { + // ===== Named partial functions for OBP Offer endpoints ===== + def obpCreateOfferPF[F[_]: Async](offer: OfferService[F]): OBPEndpoint[F] = { val dsl = new Http4sDsl[F] {}; import dsl._ + { + // POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers + case req @ POST -> Root / "obp" / "v7.0.0" / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" => + // Minimal body mapping based on trading-api-endpoints.md + case class ObpCreateOfferReq( + offer_type: String, + asset_code: String, + asset_amount: String, + price_currency: String, + price_amount: String, + expiry_datetime: Option[String], + minimum_fill: Option[String], + settlement_account_id: String + ) + req.bodyText.compile.string.flatMap { raw => + parse(raw).leftMap(_.getMessage).flatMap(_.as[ObpCreateOfferReq].leftMap(_.getMessage)) match { + case Left(msg) => BadRequest(ErrorResponse("bad_request", s"Invalid JSON: $msg")) + case Right(obp) => + val side = obp.offer_type.toUpperCase match { + case "BUY" => "BUY" + case "SELL" => "SELL" + case other => other + } + val qty = Try(BigDecimal(obp.asset_amount)).getOrElse(BigDecimal(0)) + val price = Try(BigDecimal(obp.price_amount)).getOrElse(BigDecimal(0)) + val acct = Option(obp.settlement_account_id).filter(_.nonEmpty).getOrElse(accountId) + val idKey = s"obp-${UUID.randomUUID().toString}" + val req0 = CreateOfferRequest( + offerType = side, + price = price, + quantity = qty, + accountId = acct, + idempotencyKey = idKey + ) + offer.createOffer(req0).flatMap { + case Right(ok) => Created(ok) + case Left(err) => BadRequest(err) + } + } + } + } + } - // ResourceDoc for: GET /obp/v7.0.0/.../trading/offers/{OFFER_ID} - val getObpOfferDoc: ResourceDoc = ResourceDoc( - operationId = "getObpOffer", - method = HttpMethod.GET, - path = "/obp/v7.0.0/banks/{BANK_ID}/accounts/{ACCOUNT_ID}/views/{VIEW_ID}/trading/offers/{OFFER_ID}", - summary = "Get OBP trading offer by id", - description = "Returns the trading offer details by id for the given bank/account/view.", - roles = RequiredRole.Public, - tags = Set("trading", "offer"), - requestExample = None, - responseExample = Some( - """{ - | "offerId": "OFFER-123", - | "offerType": "BUY", - | "price": 100.50, - | "quantity": 2.0, - | "remaining": 0.0, - | "status": "FILLED", - | "ownerAccountId": "ACC-001", - | "createdAt": "2025-11-03T10:20:30Z", - | "expiresAt": null - |}""".stripMargin - ), - errorResponses = List( - ErrorDoc(code = "not_found", httpStatus = 404, message = Some("Offer not found")), - ErrorDoc(code = "bad_request", httpStatus = 400, message = Some("Invalid parameters")) - ) - ) - ResourceDocRegistry.register(getObpOfferDoc) - - HttpRoutes.of[F] { - // ========== Minimal market endpoints (internal shape) ========== + // ===== Named partial functions for Minimal Market endpoints ===== + def postMarketOrdersPF[F[_]: Async](order: OrderService[F]): OBPEndpoint[F] = { + val dsl = new Http4sDsl[F] {}; import dsl._ + { // POST /market/orders case req @ POST -> Root / "market" / "orders" => req.attemptAs[CreateOrderRequest].value.flatMap { @@ -66,89 +75,122 @@ object Routes { case Left(err) => BadRequest(err) } } + } + } + def deleteMarketOrderPF[F[_]: Async](order: OrderService[F]): OBPEndpoint[F] = { + val dsl = new Http4sDsl[F] {}; import dsl._ + { // DELETE /market/orders/{id} case DELETE -> Root / "market" / "orders" / orderId => order.cancelOrder(orderId).flatMap { case Right(ok) => Ok(ok) case Left(err) => BadRequest(err) } + } + } + def getMarketOrderPF[F[_]: Async](order: OrderService[F]): OBPEndpoint[F] = { + val dsl = new Http4sDsl[F] {}; import dsl._ + { // GET /market/orders/{id} case GET -> Root / "market" / "orders" / orderId => order.getOrder(orderId).flatMap { case Right(v) => Ok(v) case Left(err) => NotFound(err) } + } + } + def postMarketMatchesPF[F[_]: Async](matcher: MatchService[F]): OBPEndpoint[F] = { + val dsl = new Http4sDsl[F] {}; import dsl._ + { // POST /market/matches case req @ POST -> Root / "market" / "matches" => Async[F].pure(Response[F](status = Status.NotImplemented)) + } + } + def postMarketSettlementsPF[F[_]: Async](settlement: SettlementService[F]): OBPEndpoint[F] = { + val dsl = new Http4sDsl[F] {}; import dsl._ + { // POST /market/settlements case req @ POST -> Root / "market" / "settlements" => Async[F].pure(Response[F](status = Status.NotImplemented)) + } + } + def getMarketTradePF[F[_]: Async](settlement: SettlementService[F]): OBPEndpoint[F] = { + val dsl = new Http4sDsl[F] {}; import dsl._ + { // GET /market/trades/{id} case GET -> Root / "market" / "trades" / tradeId => Async[F].pure(Response[F](status = Status.NotImplemented)) + } + } + def postMarketDepositsPF[F[_]: Async](funds: FundsService[F]): OBPEndpoint[F] = { + val dsl = new Http4sDsl[F] {}; import dsl._ + { // POST /market/deposits case req @ POST -> Root / "market" / "deposits" => Async[F].pure(Response[F](status = Status.NotImplemented)) + } + } + def postMarketWithdrawalsPF[F[_]: Async](funds: FundsService[F]): OBPEndpoint[F] = { + val dsl = new Http4sDsl[F] {}; import dsl._ + { // POST /market/withdrawals case req @ POST -> Root / "market" / "withdrawals" => Async[F].pure(Response[F](status = Status.NotImplemented)) + } + } - // ========== OBP-style Offer endpoints (map to OfferService) ========== - // POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers - case req @ POST -> Root / "obp" / "v7.0.0" / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" => - // Minimal body mapping based on trading-api-endpoints.md - case class ObpCreateOfferReq( - offer_type: String, - asset_code: String, - asset_amount: String, - price_currency: String, - price_amount: String, - expiry_datetime: Option[String], - minimum_fill: Option[String], - settlement_account_id: String - ) - req.bodyText.compile.string.flatMap { raw => - parse(raw).leftMap(_.getMessage).flatMap(_.as[ObpCreateOfferReq].leftMap(_.getMessage)) match { - case Left(msg) => BadRequest(ErrorResponse("bad_request", s"Invalid JSON: $msg")) - case Right(obp) => - val side = obp.offer_type.toUpperCase match { - case "BUY" => "BUY" - case "SELL" => "SELL" - case other => other - } - val qty = Try(BigDecimal(obp.asset_amount)).getOrElse(BigDecimal(0)) - val price = Try(BigDecimal(obp.price_amount)).getOrElse(BigDecimal(0)) - val acct = Option(obp.settlement_account_id).filter(_.nonEmpty).getOrElse(accountId) - val idKey = s"obp-${UUID.randomUUID().toString}" - val req0 = CreateOfferRequest( - offerType = side, - price = price, - quantity = qty, - accountId = acct, - idempotencyKey = idKey - ) - offer.createOffer(req0).flatMap { - case Right(ok) => Created(ok) - case Left(err) => BadRequest(err) - } - } - } - + // ResourceDoc for: GET /obp/v7.0.0/.../trading/offers/{OFFER_ID} + val getObpOfferDoc: ResourceDoc = ResourceDoc( + operationId = "getObpOffer", + method = HttpMethod.GET, + path = "/obp/v7.0.0/banks/{BANK_ID}/accounts/{ACCOUNT_ID}/views/{VIEW_ID}/trading/offers/{OFFER_ID}", + summary = "Get OBP trading offer by id", + description = "Returns the trading offer details by id for the given bank/account/view.", + roles = RequiredRole.Public, + tags = Set("trading", "offer"), + requestExample = None, + responseExample = Some( + """{ + | "offerId": "OFFER-123", + | "offerType": "BUY", + | "price": 100.50, + | "quantity": 2.0, + | "remaining": 0.0, + | "status": "FILLED", + | "ownerAccountId": "ACC-001", + | "createdAt": "2025-11-03T10:20:30Z", + | "expiresAt": null + |}""".stripMargin + ), + errorResponses = List( + ErrorDoc(code = "not_found", httpStatus = 404, message = Some("Offer not found")), + ErrorDoc(code = "bad_request", httpStatus = 400, message = Some("Invalid parameters")) + ) + ) + + def obpGetOfferPF[F[_]: Async](offer: OfferService[F]): OBPEndpoint[F] = { + val dsl = new Http4sDsl[F] {}; import dsl._ + { // GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID case GET -> Root / "obp" / "v7.0.0" / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / offerId => offer.getOffer(offerId).flatMap { case Right(v) => Ok(v) case Left(err) => NotFound(err) } + } + } + def obpCancelOfferPF[F[_]: Async](offer: OfferService[F]): OBPEndpoint[F] = { + val dsl = new Http4sDsl[F] {}; import dsl._ + { // DELETE /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID case DELETE -> Root / "obp" / "v7.0.0" / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / offerId => offer.cancelOffer(offerId).flatMap { @@ -157,6 +199,32 @@ object Routes { } } } + def api[F[_]: Async]( + order: OrderService[F], + offer: OfferService[F], + matcher: MatchService[F], + settlement: SettlementService[F], + funds: FundsService[F] + ): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {}; import dsl._ + + val marketPF = + postMarketOrdersPF[F](order) + .orElse(deleteMarketOrderPF[F](order)) + .orElse(getMarketOrderPF[F](order)) + .orElse(postMarketMatchesPF[F](matcher)) + .orElse(postMarketSettlementsPF[F](settlement)) + .orElse(getMarketTradePF[F](settlement)) + .orElse(postMarketDepositsPF[F](funds)) + .orElse(postMarketWithdrawalsPF[F](funds)) + val marketRoutes: HttpRoutes[F] = HttpRoutes.of[F](marketPF) + val obpOfferPF = + obpCreateOfferPF[F](offer) + .orElse(obpGetOfferPF[F](offer)) + .orElse(obpCancelOfferPF[F](offer)) + val obpOfferRoutes: HttpRoutes[F] = HttpRoutes.of[F](obpOfferPF) + marketRoutes <+> obpOfferRoutes + } } diff --git a/src/main/scala/com/openbankproject/trading/http/package.scala b/src/main/scala/com/openbankproject/trading/http/package.scala new file mode 100644 index 0000000..7d4915c --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/http/package.scala @@ -0,0 +1,53 @@ +package com.openbankproject.trading + +import org.http4s.{Request, Response} + +/** + * Package object for HTTP-related constants, type aliases, and utilities. + * + * Best practices: + * - Type aliases for common patterns + * - API constants (versions, limits, defaults) + * - Common implicit conversions + * - Small utility functions + */ +package object http { + + // ========== Type Aliases ========== + /** Type alias for HTTP endpoint partial functions */ + type OBPEndpoint[F[_]] = PartialFunction[Request[F], F[Response[F]]] + + // ========== API Constants ========== + /** Current OBP API version */ + val OBP_API_VERSION: String = "v7.0.0" + + /** Default pagination page size */ + val DEFAULT_PAGE_SIZE: Int = 50 + + /** Maximum pagination page size */ + val MAX_PAGE_SIZE: Int = 500 + + /** Default request timeout in seconds */ + val DEFAULT_TIMEOUT_SECONDS: Int = 30 + + // ========== HTTP Headers ========== + /** Custom header for request tracing */ + val HEADER_REQUEST_ID: String = "X-Request-ID" + + /** Custom header for API version */ + val HEADER_API_VERSION: String = "X-OBP-API-Version" + + // ========== Error Codes ========== + /** Standard error code constants */ + object ErrorCodes { + val BAD_REQUEST = "bad_request" + val NOT_FOUND = "not_found" + val UNAUTHORIZED = "unauthorized" + val FORBIDDEN = "forbidden" + val INTERNAL_ERROR = "internal_error" + val NOT_IMPLEMENTED = "not_implemented" + val INVALID_JSON = "invalid_json" + val VALIDATION_ERROR = "validation_error" + } +} + From 181eab4500ba32a1c0bacae22070e7166ac49636 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 28 Nov 2025 10:13:57 +0100 Subject: [PATCH 07/20] refactor/Update error handling in services and routes to use standardized error codes for better consistency and maintainability --- .../openbankproject/trading/http/HttpServerMain.scala | 10 +++++----- .../com/openbankproject/trading/http/Routes.scala | 8 ++++---- .../trading/service/InMemoryOfferService.scala | 5 +++-- .../trading/service/InMemoryOrderService.scala | 5 +++-- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala b/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala index 99cab18..4d1da62 100644 --- a/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala +++ b/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala @@ -19,17 +19,17 @@ object HttpServerMain extends IOApp.Simple { private val offerServiceIO: IO[OfferService[IO]] = InMemoryOfferService.create[IO]() private val matchService: MatchService[IO] = new MatchService[IO] { - def createMatch(req: MatchRequest) = IO.pure(Left(ErrorResponse("not_implemented", "createMatch not implemented"))) + def createMatch(req: MatchRequest) = IO.pure(Left(ErrorResponse(ErrorCodes.NOT_IMPLEMENTED, "createMatch not implemented"))) } private val settlementService: SettlementService[IO] = new SettlementService[IO] { - def settle(req: SettlementRequest) = IO.pure(Left(ErrorResponse("not_implemented", "settle not implemented"))) - def getTrade(tradeId: String) = IO.pure(Left(ErrorResponse("not_implemented", "getTrade not implemented"))) + def settle(req: SettlementRequest) = IO.pure(Left(ErrorResponse(ErrorCodes.NOT_IMPLEMENTED, "settle not implemented"))) + def getTrade(tradeId: String) = IO.pure(Left(ErrorResponse(ErrorCodes.NOT_IMPLEMENTED, "getTrade not implemented"))) } private val fundsService: FundsService[IO] = new FundsService[IO] { - def notifyDeposit(req: DepositNotification) = IO.pure(Left(ErrorResponse("not_implemented", "notifyDeposit not implemented"))) - def requestWithdrawal(req: WithdrawalRequest) = IO.pure(Left(ErrorResponse("not_implemented", "requestWithdrawal not implemented"))) + def notifyDeposit(req: DepositNotification) = IO.pure(Left(ErrorResponse(ErrorCodes.NOT_IMPLEMENTED, "notifyDeposit not implemented"))) + def requestWithdrawal(req: WithdrawalRequest) = IO.pure(Left(ErrorResponse(ErrorCodes.NOT_IMPLEMENTED, "requestWithdrawal not implemented"))) } override def run: IO[Unit] = diff --git a/src/main/scala/com/openbankproject/trading/http/Routes.scala b/src/main/scala/com/openbankproject/trading/http/Routes.scala index ddb208e..bc66e55 100644 --- a/src/main/scala/com/openbankproject/trading/http/Routes.scala +++ b/src/main/scala/com/openbankproject/trading/http/Routes.scala @@ -34,7 +34,7 @@ object Routes { ) req.bodyText.compile.string.flatMap { raw => parse(raw).leftMap(_.getMessage).flatMap(_.as[ObpCreateOfferReq].leftMap(_.getMessage)) match { - case Left(msg) => BadRequest(ErrorResponse("bad_request", s"Invalid JSON: $msg")) + case Left(msg) => BadRequest(ErrorResponse(ErrorCodes.INVALID_JSON, s"Invalid JSON: $msg")) case Right(obp) => val side = obp.offer_type.toUpperCase match { case "BUY" => "BUY" @@ -68,7 +68,7 @@ object Routes { // POST /market/orders case req @ POST -> Root / "market" / "orders" => req.attemptAs[CreateOrderRequest].value.flatMap { - case Left(df) => BadRequest(ErrorResponse("bad_request", Option(df.getMessage).getOrElse(df.toString))) + case Left(df) => BadRequest(ErrorResponse(ErrorCodes.BAD_REQUEST, Option(df.getMessage).getOrElse(df.toString))) case Right(payload) => order.createOrder(payload).flatMap { case Right(ok) => Created(ok) @@ -171,8 +171,8 @@ object Routes { |}""".stripMargin ), errorResponses = List( - ErrorDoc(code = "not_found", httpStatus = 404, message = Some("Offer not found")), - ErrorDoc(code = "bad_request", httpStatus = 400, message = Some("Invalid parameters")) + ErrorDoc(code = ErrorCodes.NOT_FOUND, httpStatus = 404, message = Some("Offer not found")), + ErrorDoc(code = ErrorCodes.BAD_REQUEST, httpStatus = 400, message = Some("Invalid parameters")) ) ) diff --git a/src/main/scala/com/openbankproject/trading/service/InMemoryOfferService.scala b/src/main/scala/com/openbankproject/trading/service/InMemoryOfferService.scala index 6fa70d5..c5820fc 100644 --- a/src/main/scala/com/openbankproject/trading/service/InMemoryOfferService.scala +++ b/src/main/scala/com/openbankproject/trading/service/InMemoryOfferService.scala @@ -4,6 +4,7 @@ import cats.effect.kernel.Async import cats.effect.Ref import cats.syntax.all._ import com.openbankproject.trading.http._ +import com.openbankproject.trading.http.ErrorCodes import java.time.Instant import java.util.UUID @@ -38,7 +39,7 @@ final class InMemoryOfferService[F[_]: Async] private ( val updated = o.copy(status = "cancelled") (m.updated(offerId, updated), Right(CancelOfferResponse(offerId, updated.status))) case None => - (m, Left(ErrorResponse("not_found", s"Offer $offerId not found"))) + (m, Left(ErrorResponse(ErrorCodes.NOT_FOUND, s"Offer $offerId not found"))) } } } @@ -58,7 +59,7 @@ final class InMemoryOfferService[F[_]: Async] private ( createdAt = o.createdAt, expiresAt = o.expiresAt )) - case None => Left(ErrorResponse("not_found", s"Offer $offerId not found")) + case None => Left(ErrorResponse(ErrorCodes.NOT_FOUND, s"Offer $offerId not found")) } } } diff --git a/src/main/scala/com/openbankproject/trading/service/InMemoryOrderService.scala b/src/main/scala/com/openbankproject/trading/service/InMemoryOrderService.scala index 996840b..4ec4427 100644 --- a/src/main/scala/com/openbankproject/trading/service/InMemoryOrderService.scala +++ b/src/main/scala/com/openbankproject/trading/service/InMemoryOrderService.scala @@ -4,6 +4,7 @@ import cats.effect.kernel.Async import cats.effect.Ref import cats.syntax.all._ import com.openbankproject.trading.http._ +import com.openbankproject.trading.http.ErrorCodes import java.time.Instant import java.util.UUID @@ -38,7 +39,7 @@ final class InMemoryOrderService[F[_]: Async] private ( val updated = o.copy(status = "cancelled") (m.updated(orderId, updated), Right(CancelOrderResponse(orderId, updated.status))) case None => - (m, Left(ErrorResponse("not_found", s"Order $orderId not found"))) + (m, Left(ErrorResponse(ErrorCodes.NOT_FOUND, s"Order $orderId not found"))) } } } @@ -58,7 +59,7 @@ final class InMemoryOrderService[F[_]: Async] private ( createdAt = o.createdAt, expiresAt = o.expiresAt )) - case None => Left(ErrorResponse("not_found", s"Order $orderId not found")) + case None => Left(ErrorResponse(ErrorCodes.NOT_FOUND, s"Order $orderId not found")) } } } From 0e8d7b95796b2fb659b5585e93191068e25a60f2 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 1 Dec 2025 10:37:05 +0100 Subject: [PATCH 08/20] feature/Refactor ResourceDoc and introduce EmptyBody marker for improved API documentation structure and clarity --- .../trading/docs/TradingResourceDocs.scala | 4 +- .../trading/docs/model/EmptyBody.scala | 11 ++++ .../trading/docs/model/ResourceDoc.scala | 50 ++++++++++++++----- .../docs/registry/ConsistencyCheck.scala | 4 +- .../docs/registry/ResourceDocRegistry.scala | 17 ++++--- .../openbankproject/trading/http/Routes.scala | 44 ++++++---------- 6 files changed, 78 insertions(+), 52 deletions(-) create mode 100644 src/main/scala/com/openbankproject/trading/docs/model/EmptyBody.scala diff --git a/src/main/scala/com/openbankproject/trading/docs/TradingResourceDocs.scala b/src/main/scala/com/openbankproject/trading/docs/TradingResourceDocs.scala index 1ee3a7e..4dcca98 100644 --- a/src/main/scala/com/openbankproject/trading/docs/TradingResourceDocs.scala +++ b/src/main/scala/com/openbankproject/trading/docs/TradingResourceDocs.scala @@ -1,6 +1,6 @@ package com.openbankproject.trading.docs -import com.openbankproject.trading.docs.model.{ErrorDoc, HttpMethod, RequiredRole, ResourceDoc} +import com.openbankproject.trading.docs.model.ResourceDoc import com.openbankproject.trading.docs.registry.ResourceDocRegistry /** @@ -8,7 +8,7 @@ import com.openbankproject.trading.docs.registry.ResourceDocRegistry */ object TradingResourceDocs { - private val docs = Seq.empty[ResourceDoc] + private val docs: Seq[ResourceDoc[F] forSome { type F[_] }] = Seq.empty def registerAll(): Unit = ResourceDocRegistry.registerAll(docs) } diff --git a/src/main/scala/com/openbankproject/trading/docs/model/EmptyBody.scala b/src/main/scala/com/openbankproject/trading/docs/model/EmptyBody.scala new file mode 100644 index 0000000..49a34b8 --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/docs/model/EmptyBody.scala @@ -0,0 +1,11 @@ +package com.openbankproject.trading.docs.model + +/** + * Marker for empty request/response body in ResourceDoc examples. + */ +case object EmptyBody extends Product with Serializable { + override def productArity: Int = 0 + override def productElement(n: Int): Any = throw new IndexOutOfBoundsException(n.toString) + override def canEqual(that: Any): Boolean = that.isInstanceOf[EmptyBody.type] +} + diff --git a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala index 867bfcb..cc1e2b3 100644 --- a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala +++ b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala @@ -1,19 +1,43 @@ package com.openbankproject.trading.docs.model -final case class ResourceDoc( - operationId: String, - method: HttpMethod, - path: String, +import org.http4s.{Request, Response} + +/** + * ResourceDoc aligned with OBP-API structure. + * Simplified version without Lift dependencies. + * + * @param partialFunction The actual partial function implementing this endpoint + * @param implementedInApiVersion API version (e.g., "v7.0.0") + * @param partialFunctionName Name of the partial function (e.g., "obpGetOfferPF") + * @param requestVerb HTTP method (GET, POST, PUT, DELETE, etc.) + * @param requestUrl URL pattern with path parameters + * @param summary Short description of the endpoint + * @param description Detailed description + * @param exampleRequestBody Example request body as case class (use EmptyBody if no body) + * @param successResponseBody Example success response as case class + * @param errorResponseBodies List of possible error messages + * @param tags Tags for categorization + * @param roles Required roles (None means public) + */ +final case class ResourceDoc[F[_]]( + partialFunction: PartialFunction[Request[F], F[Response[F]]], + implementedInApiVersion: String, + partialFunctionName: String, + requestVerb: String, + requestUrl: String, summary: String, description: String, - roles: RequiredRole = RequiredRole.Public, - tags: Set[String] = Set.empty, - requestExample: Option[String] = None, - responseExample: Option[String] = None, - errorResponses: List[ErrorDoc] = Nil + exampleRequestBody: Product, + successResponseBody: Product, + errorResponseBodies: List[String], + tags: List[String], + roles: Option[List[String]] = None, + isFeatured: Boolean = false, + specialInstructions: Option[String] = None, + specifiedUrl: Option[String] = None, + createdByBankId: Option[String] = None ) { - require(operationId.trim.nonEmpty, "operationId must be non-empty") - require(path.trim.nonEmpty, "path must be non-empty") + require(partialFunctionName.trim.nonEmpty, "partialFunctionName must be non-empty") + require(requestUrl.trim.nonEmpty, "requestUrl must be non-empty") + require(requestVerb.trim.nonEmpty, "requestVerb must be non-empty") } - - diff --git a/src/main/scala/com/openbankproject/trading/docs/registry/ConsistencyCheck.scala b/src/main/scala/com/openbankproject/trading/docs/registry/ConsistencyCheck.scala index 43a86e2..d2edf5c 100644 --- a/src/main/scala/com/openbankproject/trading/docs/registry/ConsistencyCheck.scala +++ b/src/main/scala/com/openbankproject/trading/docs/registry/ConsistencyCheck.scala @@ -3,8 +3,8 @@ package com.openbankproject.trading.docs.registry import com.openbankproject.trading.docs.model.ResourceDoc object ConsistencyCheck { - def findDuplicateOperationIds(docs: Seq[ResourceDoc]): Map[String, Int] = - docs.groupBy(_.operationId).view.mapValues(_.size).filter(_._2 > 1).toMap + def findDuplicateImplementations[F[_]](docs: Seq[ResourceDoc[F]]): Map[String, Int] = + docs.groupBy(_.partialFunctionName).view.mapValues(_.size).filter(_._2 > 1).toMap } diff --git a/src/main/scala/com/openbankproject/trading/docs/registry/ResourceDocRegistry.scala b/src/main/scala/com/openbankproject/trading/docs/registry/ResourceDocRegistry.scala index 6734f9d..122bfc6 100644 --- a/src/main/scala/com/openbankproject/trading/docs/registry/ResourceDocRegistry.scala +++ b/src/main/scala/com/openbankproject/trading/docs/registry/ResourceDocRegistry.scala @@ -4,20 +4,23 @@ import com.openbankproject.trading.docs.model.ResourceDoc import scala.collection.concurrent.TrieMap object ResourceDocRegistry { - private[this] val byOperationId: TrieMap[String, ResourceDoc] = TrieMap.empty + // Use existential type for storage + private[this] val byPartialFunctionName: TrieMap[String, ResourceDoc[F] forSome { type F[_] }] = TrieMap.empty - def register(doc: ResourceDoc): Unit = { - byOperationId.put(doc.operationId, doc) + def register[F[_]](doc: ResourceDoc[F]): Unit = { + byPartialFunctionName.put(doc.partialFunctionName, doc) () } - def registerAll(docs: Iterable[ResourceDoc]): Unit = docs.foreach(register) + def registerAll(docs: Iterable[ResourceDoc[F] forSome { type F[_] }]): Unit = docs.foreach(register(_)) - def get(operationId: String): Option[ResourceDoc] = byOperationId.get(operationId) + def get(partialFunctionName: String): Option[ResourceDoc[F] forSome { type F[_] }] = + byPartialFunctionName.get(partialFunctionName) - def all: Vector[ResourceDoc] = byOperationId.values.toVector.sortBy(_.operationId) + def all: Vector[ResourceDoc[F] forSome { type F[_] }] = + byPartialFunctionName.values.toVector.sortBy(_.partialFunctionName) - def clear(): Unit = byOperationId.clear() + def clear(): Unit = byPartialFunctionName.clear() } diff --git a/src/main/scala/com/openbankproject/trading/http/Routes.scala b/src/main/scala/com/openbankproject/trading/http/Routes.scala index bc66e55..7711233 100644 --- a/src/main/scala/com/openbankproject/trading/http/Routes.scala +++ b/src/main/scala/com/openbankproject/trading/http/Routes.scala @@ -10,8 +10,7 @@ import io.circe.generic.auto._ import io.circe.parser.parse import java.util.UUID import scala.util.Try -import com.openbankproject.trading.docs.model.{ResourceDoc, HttpMethod, RequiredRole, ErrorDoc} -import com.openbankproject.trading.docs.registry.ResourceDocRegistry +import com.openbankproject.trading.docs.model.{ResourceDoc, EmptyBody} /** Aggregated HTTP routes (interfaces only, no concrete wiring). */ object Routes { @@ -148,32 +147,23 @@ object Routes { } // ResourceDoc for: GET /obp/v7.0.0/.../trading/offers/{OFFER_ID} - val getObpOfferDoc: ResourceDoc = ResourceDoc( - operationId = "getObpOffer", - method = HttpMethod.GET, - path = "/obp/v7.0.0/banks/{BANK_ID}/accounts/{ACCOUNT_ID}/views/{VIEW_ID}/trading/offers/{OFFER_ID}", + def getObpOfferDoc[F[_]: Async](offer: OfferService[F]): ResourceDoc[F] = ResourceDoc( + partialFunction = obpGetOfferPF[F](offer), + implementedInApiVersion = "v7.0.0", + partialFunctionName = "obpGetOfferPF", + requestVerb = "GET", + requestUrl = "/obp/v7.0.0/banks/{BANK_ID}/accounts/{ACCOUNT_ID}/views/{VIEW_ID}/trading/offers/{OFFER_ID}", summary = "Get OBP trading offer by id", description = "Returns the trading offer details by id for the given bank/account/view.", - roles = RequiredRole.Public, - tags = Set("trading", "offer"), - requestExample = None, - responseExample = Some( - """{ - | "offerId": "OFFER-123", - | "offerType": "BUY", - | "price": 100.50, - | "quantity": 2.0, - | "remaining": 0.0, - | "status": "FILLED", - | "ownerAccountId": "ACC-001", - | "createdAt": "2025-11-03T10:20:30Z", - | "expiresAt": null - |}""".stripMargin - ), - errorResponses = List( - ErrorDoc(code = ErrorCodes.NOT_FOUND, httpStatus = 404, message = Some("Offer not found")), - ErrorDoc(code = ErrorCodes.BAD_REQUEST, httpStatus = 400, message = Some("Invalid parameters")) - ) + exampleRequestBody = EmptyBody, + successResponseBody = EmptyBody, + errorResponseBodies = List("not_found", "bad_request"), + tags = List("trading", "offer"), + roles = None, + isFeatured = false, + specialInstructions = None, + specifiedUrl = None, + createdByBankId = None ) def obpGetOfferPF[F[_]: Async](offer: OfferService[F]): OBPEndpoint[F] = { @@ -206,8 +196,6 @@ object Routes { settlement: SettlementService[F], funds: FundsService[F] ): HttpRoutes[F] = { - val dsl = new Http4sDsl[F] {}; import dsl._ - val marketPF = postMarketOrdersPF[F](order) .orElse(deleteMarketOrderPF[F](order)) From 99d264d2a141dd1d35405b4b9a7f4fe7eb8427ca Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 1 Dec 2025 11:56:12 +0100 Subject: [PATCH 09/20] refactor/Simplify ResourceDoc and service definitions by removing existential types, enhancing code clarity and maintainability --- .../trading/docs/TradingResourceDocs.scala | 5 +- .../trading/docs/model/ResourceDoc.scala | 8 +- .../docs/registry/ConsistencyCheck.scala | 2 +- .../docs/registry/ResourceDocRegistry.scala | 11 +-- .../trading/http/HttpServerMain.scala | 13 ++- .../openbankproject/trading/http/Routes.scala | 94 ++++++++----------- .../trading/http/package.scala | 3 +- .../service/InMemoryOfferService.scala | 20 ++-- .../service/InMemoryOrderService.scala | 20 ++-- .../trading/service/Services.scala | 33 +++---- 10 files changed, 98 insertions(+), 111 deletions(-) diff --git a/src/main/scala/com/openbankproject/trading/docs/TradingResourceDocs.scala b/src/main/scala/com/openbankproject/trading/docs/TradingResourceDocs.scala index 4dcca98..8c38d14 100644 --- a/src/main/scala/com/openbankproject/trading/docs/TradingResourceDocs.scala +++ b/src/main/scala/com/openbankproject/trading/docs/TradingResourceDocs.scala @@ -3,12 +3,9 @@ package com.openbankproject.trading.docs import com.openbankproject.trading.docs.model.ResourceDoc import com.openbankproject.trading.docs.registry.ResourceDocRegistry -/** - * Registers ResourceDocs for OBP-Trading endpoints (framework-agnostic). - */ object TradingResourceDocs { - private val docs: Seq[ResourceDoc[F] forSome { type F[_] }] = Seq.empty + private val docs: Seq[ResourceDoc] = Seq.empty def registerAll(): Unit = ResourceDocRegistry.registerAll(docs) } diff --git a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala index cc1e2b3..bee1244 100644 --- a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala +++ b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala @@ -1,11 +1,13 @@ package com.openbankproject.trading.docs.model import org.http4s.{Request, Response} +import cats.effect.IO +import com.openbankproject.trading.http.OBPEndpoint /** * ResourceDoc aligned with OBP-API structure. * Simplified version without Lift dependencies. - * + * * @param partialFunction The actual partial function implementing this endpoint * @param implementedInApiVersion API version (e.g., "v7.0.0") * @param partialFunctionName Name of the partial function (e.g., "obpGetOfferPF") @@ -19,8 +21,8 @@ import org.http4s.{Request, Response} * @param tags Tags for categorization * @param roles Required roles (None means public) */ -final case class ResourceDoc[F[_]]( - partialFunction: PartialFunction[Request[F], F[Response[F]]], +final case class ResourceDoc( + partialFunction: OBPEndpoint, implementedInApiVersion: String, partialFunctionName: String, requestVerb: String, diff --git a/src/main/scala/com/openbankproject/trading/docs/registry/ConsistencyCheck.scala b/src/main/scala/com/openbankproject/trading/docs/registry/ConsistencyCheck.scala index d2edf5c..3494488 100644 --- a/src/main/scala/com/openbankproject/trading/docs/registry/ConsistencyCheck.scala +++ b/src/main/scala/com/openbankproject/trading/docs/registry/ConsistencyCheck.scala @@ -3,7 +3,7 @@ package com.openbankproject.trading.docs.registry import com.openbankproject.trading.docs.model.ResourceDoc object ConsistencyCheck { - def findDuplicateImplementations[F[_]](docs: Seq[ResourceDoc[F]]): Map[String, Int] = + def findDuplicateImplementations(docs: Seq[ResourceDoc]): Map[String, Int] = docs.groupBy(_.partialFunctionName).view.mapValues(_.size).filter(_._2 > 1).toMap } diff --git a/src/main/scala/com/openbankproject/trading/docs/registry/ResourceDocRegistry.scala b/src/main/scala/com/openbankproject/trading/docs/registry/ResourceDocRegistry.scala index 122bfc6..81714b0 100644 --- a/src/main/scala/com/openbankproject/trading/docs/registry/ResourceDocRegistry.scala +++ b/src/main/scala/com/openbankproject/trading/docs/registry/ResourceDocRegistry.scala @@ -4,20 +4,19 @@ import com.openbankproject.trading.docs.model.ResourceDoc import scala.collection.concurrent.TrieMap object ResourceDocRegistry { - // Use existential type for storage - private[this] val byPartialFunctionName: TrieMap[String, ResourceDoc[F] forSome { type F[_] }] = TrieMap.empty + private[this] val byPartialFunctionName: TrieMap[String, ResourceDoc] = TrieMap.empty - def register[F[_]](doc: ResourceDoc[F]): Unit = { + def register(doc: ResourceDoc): Unit = { byPartialFunctionName.put(doc.partialFunctionName, doc) () } - def registerAll(docs: Iterable[ResourceDoc[F] forSome { type F[_] }]): Unit = docs.foreach(register(_)) + def registerAll(docs: Iterable[ResourceDoc]): Unit = docs.foreach(register) - def get(partialFunctionName: String): Option[ResourceDoc[F] forSome { type F[_] }] = + def get(partialFunctionName: String): Option[ResourceDoc] = byPartialFunctionName.get(partialFunctionName) - def all: Vector[ResourceDoc[F] forSome { type F[_] }] = + def all: Vector[ResourceDoc] = byPartialFunctionName.values.toVector.sortBy(_.partialFunctionName) def clear(): Unit = byPartialFunctionName.clear() diff --git a/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala b/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala index 4d1da62..4215a82 100644 --- a/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala +++ b/src/main/scala/com/openbankproject/trading/http/HttpServerMain.scala @@ -14,20 +14,19 @@ import com.typesafe.config.ConfigFactory */ object HttpServerMain extends IOApp.Simple { - private val orderServiceIO: IO[OrderService[IO]] = InMemoryOrderService.create[IO]() - - private val offerServiceIO: IO[OfferService[IO]] = InMemoryOfferService.create[IO]() + private val orderServiceIO: IO[OrderService] = InMemoryOrderService.create() + private val offerServiceIO: IO[OfferService] = InMemoryOfferService.create() - private val matchService: MatchService[IO] = new MatchService[IO] { + private val matchService: MatchService = new MatchService { def createMatch(req: MatchRequest) = IO.pure(Left(ErrorResponse(ErrorCodes.NOT_IMPLEMENTED, "createMatch not implemented"))) } - private val settlementService: SettlementService[IO] = new SettlementService[IO] { + private val settlementService: SettlementService = new SettlementService { def settle(req: SettlementRequest) = IO.pure(Left(ErrorResponse(ErrorCodes.NOT_IMPLEMENTED, "settle not implemented"))) def getTrade(tradeId: String) = IO.pure(Left(ErrorResponse(ErrorCodes.NOT_IMPLEMENTED, "getTrade not implemented"))) } - private val fundsService: FundsService[IO] = new FundsService[IO] { + private val fundsService: FundsService = new FundsService { def notifyDeposit(req: DepositNotification) = IO.pure(Left(ErrorResponse(ErrorCodes.NOT_IMPLEMENTED, "notifyDeposit not implemented"))) def requestWithdrawal(req: WithdrawalRequest) = IO.pure(Left(ErrorResponse(ErrorCodes.NOT_IMPLEMENTED, "requestWithdrawal not implemented"))) } @@ -39,7 +38,7 @@ object HttpServerMain extends IOApp.Simple { serverPort = config.getString("server.port").toInt offerService <- offerServiceIO orderService <- orderServiceIO - apiRoutes = Routes.api[IO](orderService, offerService, matchService, settlementService, fundsService) + apiRoutes = Routes.api(orderService, offerService, matchService, settlementService, fundsService) httpApp = Router("/" -> apiRoutes).orNotFound host <- IO.fromOption(Host.fromString(serverHost))(new IllegalArgumentException(s"Invalid host: $serverHost")) port <- IO.fromOption(Port.fromInt(serverPort))(new IllegalArgumentException(s"Invalid port: $serverPort")) diff --git a/src/main/scala/com/openbankproject/trading/http/Routes.scala b/src/main/scala/com/openbankproject/trading/http/Routes.scala index 7711233..cd82cc8 100644 --- a/src/main/scala/com/openbankproject/trading/http/Routes.scala +++ b/src/main/scala/com/openbankproject/trading/http/Routes.scala @@ -1,8 +1,7 @@ package com.openbankproject.trading.http import org.http4s._ -import org.http4s.dsl.Http4sDsl -import cats.effect.Async +import org.http4s.dsl.io._ import cats.syntax.all._ import com.openbankproject.trading.service._ import org.http4s.circe.CirceEntityCodec._ @@ -11,12 +10,12 @@ import io.circe.parser.parse import java.util.UUID import scala.util.Try import com.openbankproject.trading.docs.model.{ResourceDoc, EmptyBody} +import cats.effect.IO /** Aggregated HTTP routes (interfaces only, no concrete wiring). */ object Routes { // ===== Named partial functions for OBP Offer endpoints ===== - def obpCreateOfferPF[F[_]: Async](offer: OfferService[F]): OBPEndpoint[F] = { - val dsl = new Http4sDsl[F] {}; import dsl._ + def obpCreateOfferPF(offer: OfferService): OBPEndpoint = { { // POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers case req @ POST -> Root / "obp" / "v7.0.0" / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" => @@ -61,8 +60,7 @@ object Routes { } // ===== Named partial functions for Minimal Market endpoints ===== - def postMarketOrdersPF[F[_]: Async](order: OrderService[F]): OBPEndpoint[F] = { - val dsl = new Http4sDsl[F] {}; import dsl._ + def postMarketOrdersPF(order: OrderService): OBPEndpoint = { { // POST /market/orders case req @ POST -> Root / "market" / "orders" => @@ -77,8 +75,7 @@ object Routes { } } - def deleteMarketOrderPF[F[_]: Async](order: OrderService[F]): OBPEndpoint[F] = { - val dsl = new Http4sDsl[F] {}; import dsl._ + def deleteMarketOrderPF(order: OrderService): OBPEndpoint = { { // DELETE /market/orders/{id} case DELETE -> Root / "market" / "orders" / orderId => @@ -89,8 +86,7 @@ object Routes { } } - def getMarketOrderPF[F[_]: Async](order: OrderService[F]): OBPEndpoint[F] = { - val dsl = new Http4sDsl[F] {}; import dsl._ + def getMarketOrderPF(order: OrderService): OBPEndpoint = { { // GET /market/orders/{id} case GET -> Root / "market" / "orders" / orderId => @@ -101,54 +97,49 @@ object Routes { } } - def postMarketMatchesPF[F[_]: Async](matcher: MatchService[F]): OBPEndpoint[F] = { - val dsl = new Http4sDsl[F] {}; import dsl._ + def postMarketMatchesPF(matcher: MatchService): OBPEndpoint = { { // POST /market/matches case req @ POST -> Root / "market" / "matches" => - Async[F].pure(Response[F](status = Status.NotImplemented)) + IO.pure(Response[IO](status = Status.NotImplemented)) } } - def postMarketSettlementsPF[F[_]: Async](settlement: SettlementService[F]): OBPEndpoint[F] = { - val dsl = new Http4sDsl[F] {}; import dsl._ + def postMarketSettlementsPF(settlement: SettlementService): OBPEndpoint = { { // POST /market/settlements case req @ POST -> Root / "market" / "settlements" => - Async[F].pure(Response[F](status = Status.NotImplemented)) + IO.pure(Response[IO](status = Status.NotImplemented)) } } - def getMarketTradePF[F[_]: Async](settlement: SettlementService[F]): OBPEndpoint[F] = { - val dsl = new Http4sDsl[F] {}; import dsl._ + def getMarketTradePF(settlement: SettlementService): OBPEndpoint = { { // GET /market/trades/{id} case GET -> Root / "market" / "trades" / tradeId => - Async[F].pure(Response[F](status = Status.NotImplemented)) + IO.pure(Response[IO](status = Status.NotImplemented)) } } - def postMarketDepositsPF[F[_]: Async](funds: FundsService[F]): OBPEndpoint[F] = { - val dsl = new Http4sDsl[F] {}; import dsl._ + def postMarketDepositsPF(funds: FundsService): OBPEndpoint = { { // POST /market/deposits case req @ POST -> Root / "market" / "deposits" => - Async[F].pure(Response[F](status = Status.NotImplemented)) + IO.pure(Response[IO](status = Status.NotImplemented)) } } - def postMarketWithdrawalsPF[F[_]: Async](funds: FundsService[F]): OBPEndpoint[F] = { - val dsl = new Http4sDsl[F] {}; import dsl._ + def postMarketWithdrawalsPF(funds: FundsService): OBPEndpoint = { { // POST /market/withdrawals case req @ POST -> Root / "market" / "withdrawals" => - Async[F].pure(Response[F](status = Status.NotImplemented)) + IO.pure(Response[IO](status = Status.NotImplemented)) } } // ResourceDoc for: GET /obp/v7.0.0/.../trading/offers/{OFFER_ID} - def getObpOfferDoc[F[_]: Async](offer: OfferService[F]): ResourceDoc[F] = ResourceDoc( - partialFunction = obpGetOfferPF[F](offer), + def getObpOfferDoc(offer: OfferService): ResourceDoc = ResourceDoc( + partialFunction = obpGetOfferPF(offer), implementedInApiVersion = "v7.0.0", partialFunctionName = "obpGetOfferPF", requestVerb = "GET", @@ -165,11 +156,9 @@ object Routes { specifiedUrl = None, createdByBankId = None ) - - def obpGetOfferPF[F[_]: Async](offer: OfferService[F]): OBPEndpoint[F] = { - val dsl = new Http4sDsl[F] {}; import dsl._ + + def obpGetOfferPF(offer: OfferService): OBPEndpoint = { { - // GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID case GET -> Root / "obp" / "v7.0.0" / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / offerId => offer.getOffer(offerId).flatMap { case Right(v) => Ok(v) @@ -178,8 +167,7 @@ object Routes { } } - def obpCancelOfferPF[F[_]: Async](offer: OfferService[F]): OBPEndpoint[F] = { - val dsl = new Http4sDsl[F] {}; import dsl._ + def obpCancelOfferPF(offer: OfferService): OBPEndpoint = { { // DELETE /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID case DELETE -> Root / "obp" / "v7.0.0" / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / offerId => @@ -189,28 +177,28 @@ object Routes { } } } - def api[F[_]: Async]( - order: OrderService[F], - offer: OfferService[F], - matcher: MatchService[F], - settlement: SettlementService[F], - funds: FundsService[F] - ): HttpRoutes[F] = { + def api( + order: OrderService, + offer: OfferService, + matcher: MatchService, + settlement: SettlementService, + funds: FundsService + ): HttpRoutes[IO] = { val marketPF = - postMarketOrdersPF[F](order) - .orElse(deleteMarketOrderPF[F](order)) - .orElse(getMarketOrderPF[F](order)) - .orElse(postMarketMatchesPF[F](matcher)) - .orElse(postMarketSettlementsPF[F](settlement)) - .orElse(getMarketTradePF[F](settlement)) - .orElse(postMarketDepositsPF[F](funds)) - .orElse(postMarketWithdrawalsPF[F](funds)) - val marketRoutes: HttpRoutes[F] = HttpRoutes.of[F](marketPF) + postMarketOrdersPF(order) + .orElse(deleteMarketOrderPF(order)) + .orElse(getMarketOrderPF(order)) + .orElse(postMarketMatchesPF(matcher)) + .orElse(postMarketSettlementsPF(settlement)) + .orElse(getMarketTradePF(settlement)) + .orElse(postMarketDepositsPF(funds)) + .orElse(postMarketWithdrawalsPF(funds)) + val marketRoutes: HttpRoutes[IO] = HttpRoutes.of[IO](marketPF) val obpOfferPF = - obpCreateOfferPF[F](offer) - .orElse(obpGetOfferPF[F](offer)) - .orElse(obpCancelOfferPF[F](offer)) - val obpOfferRoutes: HttpRoutes[F] = HttpRoutes.of[F](obpOfferPF) + obpCreateOfferPF(offer) + .orElse(obpGetOfferPF(offer)) + .orElse(obpCancelOfferPF(offer)) + val obpOfferRoutes: HttpRoutes[IO] = HttpRoutes.of[IO](obpOfferPF) marketRoutes <+> obpOfferRoutes } } diff --git a/src/main/scala/com/openbankproject/trading/http/package.scala b/src/main/scala/com/openbankproject/trading/http/package.scala index 7d4915c..8a7b5b9 100644 --- a/src/main/scala/com/openbankproject/trading/http/package.scala +++ b/src/main/scala/com/openbankproject/trading/http/package.scala @@ -1,6 +1,7 @@ package com.openbankproject.trading import org.http4s.{Request, Response} +import cats.effect.IO /** * Package object for HTTP-related constants, type aliases, and utilities. @@ -15,7 +16,7 @@ package object http { // ========== Type Aliases ========== /** Type alias for HTTP endpoint partial functions */ - type OBPEndpoint[F[_]] = PartialFunction[Request[F], F[Response[F]]] + type OBPEndpoint = PartialFunction[Request[IO], IO[Response[IO]]] // ========== API Constants ========== /** Current OBP API version */ diff --git a/src/main/scala/com/openbankproject/trading/service/InMemoryOfferService.scala b/src/main/scala/com/openbankproject/trading/service/InMemoryOfferService.scala index c5820fc..ebb55d5 100644 --- a/src/main/scala/com/openbankproject/trading/service/InMemoryOfferService.scala +++ b/src/main/scala/com/openbankproject/trading/service/InMemoryOfferService.scala @@ -1,6 +1,6 @@ package com.openbankproject.trading.service -import cats.effect.kernel.Async +import cats.effect.IO import cats.effect.Ref import cats.syntax.all._ import com.openbankproject.trading.http._ @@ -9,13 +9,13 @@ import com.openbankproject.trading.http.ErrorCodes import java.time.Instant import java.util.UUID -final class InMemoryOfferService[F[_]: Async] private ( - state: Ref[F, Map[String, InMemoryOfferService.StoredOffer]] -) extends OfferService[F] { +final class InMemoryOfferService private ( + state: Ref[IO, Map[String, InMemoryOfferService.StoredOffer]] +) extends OfferService { import InMemoryOfferService.StoredOffer - def createOffer(req: CreateOfferRequest): F[Either[ErrorResponse, CreateOfferResponse]] = { + def createOffer(req: CreateOfferRequest): IO[Either[ErrorResponse, CreateOfferResponse]] = { val id = UUID.randomUUID().toString val now = Instant.now() val stored = StoredOffer( @@ -29,10 +29,10 @@ final class InMemoryOfferService[F[_]: Async] private ( createdAt = now, expiresAt = None ) - state.update(_ + (id -> stored)) *> Async[F].pure(Right(CreateOfferResponse(id, stored.status, stored.remaining))) + state.update(_ + (id -> stored)) *> IO.pure(Right(CreateOfferResponse(id, stored.status, stored.remaining))) } - def cancelOffer(offerId: String): F[Either[ErrorResponse, CancelOfferResponse]] = { + def cancelOffer(offerId: String): IO[Either[ErrorResponse, CancelOfferResponse]] = { state.modify { m => m.get(offerId) match { case Some(o) => @@ -44,7 +44,7 @@ final class InMemoryOfferService[F[_]: Async] private ( } } - def getOffer(offerId: String): F[Either[ErrorResponse, OfferView]] = { + def getOffer(offerId: String): IO[Either[ErrorResponse, OfferView]] = { state.get.map { m => m.get(offerId) match { case Some(o) => @@ -78,8 +78,8 @@ object InMemoryOfferService { expiresAt: Option[Instant] ) - def create[F[_]: Async](): F[InMemoryOfferService[F]] = - Ref.of[F, Map[String, StoredOffer]](Map.empty).map(ref => new InMemoryOfferService[F](ref)) + def create(): IO[InMemoryOfferService] = + Ref.of[IO, Map[String, StoredOffer]](Map.empty).map(ref => new InMemoryOfferService(ref)) } diff --git a/src/main/scala/com/openbankproject/trading/service/InMemoryOrderService.scala b/src/main/scala/com/openbankproject/trading/service/InMemoryOrderService.scala index 4ec4427..03fb84c 100644 --- a/src/main/scala/com/openbankproject/trading/service/InMemoryOrderService.scala +++ b/src/main/scala/com/openbankproject/trading/service/InMemoryOrderService.scala @@ -1,6 +1,6 @@ package com.openbankproject.trading.service -import cats.effect.kernel.Async +import cats.effect.IO import cats.effect.Ref import cats.syntax.all._ import com.openbankproject.trading.http._ @@ -9,13 +9,13 @@ import com.openbankproject.trading.http.ErrorCodes import java.time.Instant import java.util.UUID -final class InMemoryOrderService[F[_]: Async] private ( - state: Ref[F, Map[String, InMemoryOrderService.StoredOrder]] -) extends OrderService[F] { +final class InMemoryOrderService private ( + state: Ref[IO, Map[String, InMemoryOrderService.StoredOrder]] +) extends OrderService { import InMemoryOrderService.StoredOrder - def createOrder(req: CreateOrderRequest): F[Either[ErrorResponse, CreateOrderResponse]] = { + def createOrder(req: CreateOrderRequest): IO[Either[ErrorResponse, CreateOrderResponse]] = { val id = UUID.randomUUID().toString val now = Instant.now() val stored = StoredOrder( @@ -29,10 +29,10 @@ final class InMemoryOrderService[F[_]: Async] private ( createdAt = now, expiresAt = None ) - state.update(_ + (id -> stored)) *> Async[F].pure(Right(CreateOrderResponse(id, stored.status, stored.remaining))) + state.update(_ + (id -> stored)) *> IO.pure(Right(CreateOrderResponse(id, stored.status, stored.remaining))) } - def cancelOrder(orderId: String): F[Either[ErrorResponse, CancelOrderResponse]] = { + def cancelOrder(orderId: String): IO[Either[ErrorResponse, CancelOrderResponse]] = { state.modify { m => m.get(orderId) match { case Some(o) => @@ -44,7 +44,7 @@ final class InMemoryOrderService[F[_]: Async] private ( } } - def getOrder(orderId: String): F[Either[ErrorResponse, OrderView]] = { + def getOrder(orderId: String): IO[Either[ErrorResponse, OrderView]] = { state.get.map { m => m.get(orderId) match { case Some(o) => @@ -78,8 +78,8 @@ object InMemoryOrderService { expiresAt: Option[Instant] ) - def create[F[_]: Async](): F[InMemoryOrderService[F]] = - Ref.of[F, Map[String, StoredOrder]](Map.empty).map(ref => new InMemoryOrderService[F](ref)) + def create(): IO[InMemoryOrderService] = + Ref.of[IO, Map[String, StoredOrder]](Map.empty).map(ref => new InMemoryOrderService(ref)) } diff --git a/src/main/scala/com/openbankproject/trading/service/Services.scala b/src/main/scala/com/openbankproject/trading/service/Services.scala index e987162..7e6abd0 100644 --- a/src/main/scala/com/openbankproject/trading/service/Services.scala +++ b/src/main/scala/com/openbankproject/trading/service/Services.scala @@ -1,31 +1,32 @@ package com.openbankproject.trading.service import com.openbankproject.trading.http._ +import cats.effect.IO -trait OrderService[F[_]] { - def createOrder(req: CreateOrderRequest): F[Either[ErrorResponse, CreateOrderResponse]] - def cancelOrder(orderId: String): F[Either[ErrorResponse, CancelOrderResponse]] - def getOrder(orderId: String): F[Either[ErrorResponse, OrderView]] +trait OrderService { + def createOrder(req: CreateOrderRequest): IO[Either[ErrorResponse, CreateOrderResponse]] + def cancelOrder(orderId: String): IO[Either[ErrorResponse, CancelOrderResponse]] + def getOrder(orderId: String): IO[Either[ErrorResponse, OrderView]] } -trait OfferService[F[_]] { - def createOffer(req: CreateOfferRequest): F[Either[ErrorResponse, CreateOfferResponse]] - def cancelOffer(offerId: String): F[Either[ErrorResponse, CancelOfferResponse]] - def getOffer(offerId: String): F[Either[ErrorResponse, OfferView]] +trait OfferService { + def createOffer(req: CreateOfferRequest): IO[Either[ErrorResponse, CreateOfferResponse]] + def cancelOffer(offerId: String): IO[Either[ErrorResponse, CancelOfferResponse]] + def getOffer(offerId: String): IO[Either[ErrorResponse, OfferView]] } -trait MatchService[F[_]] { - def createMatch(req: MatchRequest): F[Either[ErrorResponse, CreateMatchResponse]] +trait MatchService { + def createMatch(req: MatchRequest): IO[Either[ErrorResponse, CreateMatchResponse]] } -trait SettlementService[F[_]] { - def settle(req: SettlementRequest): F[Either[ErrorResponse, SettlementResponse]] - def getTrade(tradeId: String): F[Either[ErrorResponse, TradeView]] +trait SettlementService { + def settle(req: SettlementRequest): IO[Either[ErrorResponse, SettlementResponse]] + def getTrade(tradeId: String): IO[Either[ErrorResponse, TradeView]] } -trait FundsService[F[_]] { - def notifyDeposit(req: DepositNotification): F[Either[ErrorResponse, DepositResponse]] - def requestWithdrawal(req: WithdrawalRequest): F[Either[ErrorResponse, WithdrawalResponse]] +trait FundsService { + def notifyDeposit(req: DepositNotification): IO[Either[ErrorResponse, DepositResponse]] + def requestWithdrawal(req: WithdrawalRequest): IO[Either[ErrorResponse, WithdrawalResponse]] } From 80155d3c0633807752f33c0816b21ae744cd9570 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 1 Dec 2025 15:30:16 +0100 Subject: [PATCH 10/20] feature/Add ImplementedByJson and ResourceDocJson case classes to enhance API documentation structure and serialization support --- .../trading/docs/model/ResourceDoc.scala | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala index bee1244..2d8a089 100644 --- a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala +++ b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala @@ -3,6 +3,7 @@ package com.openbankproject.trading.docs.model import org.http4s.{Request, Response} import cats.effect.IO import com.openbankproject.trading.http.OBPEndpoint +import io.circe.Json /** * ResourceDoc aligned with OBP-API structure. @@ -43,3 +44,50 @@ final case class ResourceDoc( require(requestUrl.trim.nonEmpty, "requestUrl must be non-empty") require(requestVerb.trim.nonEmpty, "requestVerb must be non-empty") } + +/** + * Used to describe where an API call is implemented, similar to OBP's ImplementedByJson. + */ +final case class ImplementedByJson( + version: String, // Short hand for version, e.g. "v7_0_0" + function: String // The partial function name, e.g. "obpGetOfferPF" +) + +/** + * Export-friendly ResourceDocJson, mirroring OBP-API structure for docs/export. + */ +final case class ResourceDocJson( + operation_id: String, + implemented_by: ImplementedByJson, + request_url: String, + summary: String, + description: String, + description_markdown: String, + example_request_body: Json, + success_response_body: Json, + error_response_bodies: List[String], + tags: List[String], + typed_request_body: Json, + typed_success_response_body: Json, + roles: Option[List[String]] = None, + is_featured: Boolean = false, + special_instructions: Option[String] = None, + specified_url: Option[String] = None, + connector_methods: List[String] = Nil, + created_by_bank_id: Option[String] = None +) + +object ResourceDocJson { + import io.circe.generic.semiauto._ + import io.circe.{Encoder, Decoder} + + implicit val implementedByJsonEncoder: Encoder[ImplementedByJson] = + deriveEncoder[ImplementedByJson] + implicit val implementedByJsonDecoder: Decoder[ImplementedByJson] = + deriveDecoder[ImplementedByJson] + + implicit val resourceDocJsonEncoder: Encoder[ResourceDocJson] = + deriveEncoder[ResourceDocJson] + implicit val resourceDocJsonDecoder: Decoder[ResourceDocJson] = + deriveDecoder[ResourceDocJson] +} From c1d0d005ad5cd786df8dc24ebdbd5d1ed859e42c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 1 Dec 2025 23:34:09 +0100 Subject: [PATCH 11/20] feature/Enhance API documentation by adding resource documentation endpoint and JSON serialization support for ResourceDoc --- .../openbankproject/trading/http/Routes.scala | 42 ++++++++++++++++++- .../trading/http/OfferRoutesTest.scala | 18 ++++---- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/main/scala/com/openbankproject/trading/http/Routes.scala b/src/main/scala/com/openbankproject/trading/http/Routes.scala index cd82cc8..ad1533a 100644 --- a/src/main/scala/com/openbankproject/trading/http/Routes.scala +++ b/src/main/scala/com/openbankproject/trading/http/Routes.scala @@ -9,8 +9,11 @@ import io.circe.generic.auto._ import io.circe.parser.parse import java.util.UUID import scala.util.Try -import com.openbankproject.trading.docs.model.{ResourceDoc, EmptyBody} +import com.openbankproject.trading.docs.model.{ResourceDoc, EmptyBody, ResourceDocJson, ImplementedByJson} +import com.openbankproject.trading.docs.registry.ResourceDocRegistry import cats.effect.IO +import io.circe.syntax._ +import io.circe.Json /** Aggregated HTTP routes (interfaces only, no concrete wiring). */ object Routes { @@ -177,6 +180,41 @@ object Routes { } } } + private def productToJson(p: Product): Json = p match { + case EmptyBody => Json.Null + case other => Json.fromString(other.toString) + } + + private def toResourceDocJson(doc: ResourceDoc): ResourceDocJson = + ResourceDocJson( + operation_id = doc.partialFunctionName, + implemented_by = ImplementedByJson(doc.implementedInApiVersion, doc.partialFunctionName), + request_url = doc.requestUrl, + summary = doc.summary, + description = doc.description, + description_markdown = doc.description, + example_request_body = productToJson(doc.exampleRequestBody), + success_response_body = productToJson(doc.successResponseBody), + error_response_bodies = doc.errorResponseBodies, + tags = doc.tags, + typed_request_body = Json.Null, + typed_success_response_body = Json.Null, + roles = doc.roles, + is_featured = doc.isFeatured, + special_instructions = doc.specialInstructions, + specified_url = doc.specifiedUrl, + connector_methods = Nil, + created_by_bank_id = doc.createdByBankId + ) + + def getResourceDocsPF: OBPEndpoint = { + { + case GET -> Root / "obp" / "v7.0.0" / "resource-docs" / apiVersion / "obp" => + val docs = ResourceDocRegistry.all.map(toResourceDocJson) + Ok(docs.asJson) + } + } + def api( order: OrderService, offer: OfferService, @@ -184,6 +222,7 @@ object Routes { settlement: SettlementService, funds: FundsService ): HttpRoutes[IO] = { + ResourceDocRegistry.register(getObpOfferDoc(offer)) val marketPF = postMarketOrdersPF(order) .orElse(deleteMarketOrderPF(order)) @@ -198,6 +237,7 @@ object Routes { obpCreateOfferPF(offer) .orElse(obpGetOfferPF(offer)) .orElse(obpCancelOfferPF(offer)) + .orElse(getResourceDocsPF) val obpOfferRoutes: HttpRoutes[IO] = HttpRoutes.of[IO](obpOfferPF) marketRoutes <+> obpOfferRoutes } diff --git a/src/test/scala/com/openbankproject/trading/http/OfferRoutesTest.scala b/src/test/scala/com/openbankproject/trading/http/OfferRoutesTest.scala index 6653664..accf10b 100644 --- a/src/test/scala/com/openbankproject/trading/http/OfferRoutesTest.scala +++ b/src/test/scala/com/openbankproject/trading/http/OfferRoutesTest.scala @@ -14,28 +14,28 @@ import org.scalatest.matchers.should.Matchers class OfferRoutesTest extends AnyFunSuite with Matchers { - private def app(offerService: OfferService[IO]) = { - val orderStub: OrderService[IO] = new OrderService[IO] { + private def app(offerService: OfferService) = { + val orderStub: OrderService = new OrderService { def createOrder(req: CreateOrderRequest) = IO.pure(Left(ErrorResponse("not_implemented", "order create not used in this test"))) def cancelOrder(orderId: String) = IO.pure(Left(ErrorResponse("not_implemented", "order cancel not used in this test"))) def getOrder(orderId: String) = IO.pure(Left(ErrorResponse("not_found", s"order $orderId not found"))) } - val matchStub: MatchService[IO] = new MatchService[IO] { + val matchStub: MatchService = new MatchService { def createMatch(req: MatchRequest) = IO.pure(Left(ErrorResponse("not_implemented", "match not used in this test"))) } - val settlementStub: SettlementService[IO] = new SettlementService[IO] { + val settlementStub: SettlementService = new SettlementService { def settle(req: SettlementRequest) = IO.pure(Left(ErrorResponse("not_implemented", "settle not used in this test"))) def getTrade(tradeId: String) = IO.pure(Left(ErrorResponse("not_implemented", "getTrade not used in this test"))) } - val fundsStub: FundsService[IO] = new FundsService[IO] { + val fundsStub: FundsService = new FundsService { def notifyDeposit(req: DepositNotification) = IO.pure(Left(ErrorResponse("not_implemented", "notifyDeposit not used in this test"))) def requestWithdrawal(req: WithdrawalRequest) = IO.pure(Left(ErrorResponse("not_implemented", "requestWithdrawal not used in this test"))) } - Routes.api[IO](orderStub, offerService, matchStub, settlementStub, fundsStub).orNotFound + Routes.api(orderStub, offerService, matchStub, settlementStub, fundsStub).orNotFound } test("create -> get -> cancel offer happy path") { - val service = InMemoryOfferService.create[IO]().unsafeRunSync() + val service = InMemoryOfferService.create().unsafeRunSync() val httpApp = app(service) val bankId = "bank-1" @@ -84,7 +84,7 @@ class OfferRoutesTest extends AnyFunSuite with Matchers { } test("invalid JSON returns 400") { - val service = InMemoryOfferService.create[IO]().unsafeRunSync() + val service = InMemoryOfferService.create().unsafeRunSync() val httpApp = app(service) val req = Request[IO]( @@ -97,7 +97,7 @@ class OfferRoutesTest extends AnyFunSuite with Matchers { } test("get non-existing offer returns 404") { - val service = InMemoryOfferService.create[IO]().unsafeRunSync() + val service = InMemoryOfferService.create().unsafeRunSync() val httpApp = app(service) val req = Request[IO]( From bfa60dd6d18c0622dfbfde8069503b7ab09a82a6 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 1 Dec 2025 23:45:04 +0100 Subject: [PATCH 12/20] refactor/Update partial function names and improve consistency in API route definitions for offer management --- .../trading/docs/model/ResourceDoc.scala | 4 +-- .../openbankproject/trading/http/Routes.scala | 30 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala index 2d8a089..54349db 100644 --- a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala +++ b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala @@ -11,7 +11,7 @@ import io.circe.Json * * @param partialFunction The actual partial function implementing this endpoint * @param implementedInApiVersion API version (e.g., "v7.0.0") - * @param partialFunctionName Name of the partial function (e.g., "obpGetOfferPF") + * @param partialFunctionName Name of the partial function (e.g., "getOfferPF") * @param requestVerb HTTP method (GET, POST, PUT, DELETE, etc.) * @param requestUrl URL pattern with path parameters * @param summary Short description of the endpoint @@ -50,7 +50,7 @@ final case class ResourceDoc( */ final case class ImplementedByJson( version: String, // Short hand for version, e.g. "v7_0_0" - function: String // The partial function name, e.g. "obpGetOfferPF" + function: String // The partial function name, e.g. "getOfferPF" ) /** diff --git a/src/main/scala/com/openbankproject/trading/http/Routes.scala b/src/main/scala/com/openbankproject/trading/http/Routes.scala index ad1533a..c673a7b 100644 --- a/src/main/scala/com/openbankproject/trading/http/Routes.scala +++ b/src/main/scala/com/openbankproject/trading/http/Routes.scala @@ -18,12 +18,12 @@ import io.circe.Json /** Aggregated HTTP routes (interfaces only, no concrete wiring). */ object Routes { // ===== Named partial functions for OBP Offer endpoints ===== - def obpCreateOfferPF(offer: OfferService): OBPEndpoint = { + def createOfferPF(offer: OfferService): OBPEndpoint = { { // POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers case req @ POST -> Root / "obp" / "v7.0.0" / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" => // Minimal body mapping based on trading-api-endpoints.md - case class ObpCreateOfferReq( + case class CreateOfferReq( offer_type: String, asset_code: String, asset_amount: String, @@ -34,7 +34,7 @@ object Routes { settlement_account_id: String ) req.bodyText.compile.string.flatMap { raw => - parse(raw).leftMap(_.getMessage).flatMap(_.as[ObpCreateOfferReq].leftMap(_.getMessage)) match { + parse(raw).leftMap(_.getMessage).flatMap(_.as[CreateOfferReq].leftMap(_.getMessage)) match { case Left(msg) => BadRequest(ErrorResponse(ErrorCodes.INVALID_JSON, s"Invalid JSON: $msg")) case Right(obp) => val side = obp.offer_type.toUpperCase match { @@ -141,10 +141,10 @@ object Routes { } // ResourceDoc for: GET /obp/v7.0.0/.../trading/offers/{OFFER_ID} - def getObpOfferDoc(offer: OfferService): ResourceDoc = ResourceDoc( - partialFunction = obpGetOfferPF(offer), + def getOfferDoc(offer: OfferService): ResourceDoc = ResourceDoc( + partialFunction = getOfferPF(offer), implementedInApiVersion = "v7.0.0", - partialFunctionName = "obpGetOfferPF", + partialFunctionName = "getOfferPF", requestVerb = "GET", requestUrl = "/obp/v7.0.0/banks/{BANK_ID}/accounts/{ACCOUNT_ID}/views/{VIEW_ID}/trading/offers/{OFFER_ID}", summary = "Get OBP trading offer by id", @@ -160,7 +160,7 @@ object Routes { createdByBankId = None ) - def obpGetOfferPF(offer: OfferService): OBPEndpoint = { + def getOfferPF(offer: OfferService): OBPEndpoint = { { case GET -> Root / "obp" / "v7.0.0" / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / offerId => offer.getOffer(offerId).flatMap { @@ -170,7 +170,7 @@ object Routes { } } - def obpCancelOfferPF(offer: OfferService): OBPEndpoint = { + def cancelOfferPF(offer: OfferService): OBPEndpoint = { { // DELETE /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID case DELETE -> Root / "obp" / "v7.0.0" / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / offerId => @@ -222,7 +222,7 @@ object Routes { settlement: SettlementService, funds: FundsService ): HttpRoutes[IO] = { - ResourceDocRegistry.register(getObpOfferDoc(offer)) + ResourceDocRegistry.register(getOfferDoc(offer)) val marketPF = postMarketOrdersPF(order) .orElse(deleteMarketOrderPF(order)) @@ -233,13 +233,13 @@ object Routes { .orElse(postMarketDepositsPF(funds)) .orElse(postMarketWithdrawalsPF(funds)) val marketRoutes: HttpRoutes[IO] = HttpRoutes.of[IO](marketPF) - val obpOfferPF = - obpCreateOfferPF(offer) - .orElse(obpGetOfferPF(offer)) - .orElse(obpCancelOfferPF(offer)) + val offerPF = + createOfferPF(offer) + .orElse(getOfferPF(offer)) + .orElse(cancelOfferPF(offer)) .orElse(getResourceDocsPF) - val obpOfferRoutes: HttpRoutes[IO] = HttpRoutes.of[IO](obpOfferPF) - marketRoutes <+> obpOfferRoutes + val offerRoutes: HttpRoutes[IO] = HttpRoutes.of[IO](offerPF) + marketRoutes <+> offerRoutes } } From 50ca13cece7c51d9690ec1f9fddf1ef45d768afa Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 2 Dec 2025 00:33:28 +0100 Subject: [PATCH 13/20] feature/Add detailed examples for trading offer API responses, enhancing API documentation and JSON serialization for offer management --- .../openbankproject/trading/http/Routes.scala | 107 +++++++++++++++++- 1 file changed, 102 insertions(+), 5 deletions(-) diff --git a/src/main/scala/com/openbankproject/trading/http/Routes.scala b/src/main/scala/com/openbankproject/trading/http/Routes.scala index c673a7b..167528c 100644 --- a/src/main/scala/com/openbankproject/trading/http/Routes.scala +++ b/src/main/scala/com/openbankproject/trading/http/Routes.scala @@ -140,17 +140,80 @@ object Routes { } } + // Helper case classes for ResourceDoc examples + private final case class ObpOfferExecutionExample( + execution_id: String, + executed_amount: String, + executed_price: String, + executed_at: String, + counterpart_offer_id: String + ) + private final case class ObpOfferDetailsExample( + offer_type: String, + asset_code: String, + asset_amount: String, + filled_amount: String, + remaining_amount: String, + price_currency: String, + price_amount: String, + expiry_datetime: String, + minimum_fill: String + ) + private final case class ObpOfferAccountInfoExample( + bank_id: String, + account_id: String, + view_id: String + ) + private final case class ObpOfferResponseExample( + offer_id: String, + status: String, + created_at: String, + updated_at: String, + offer_details: ObpOfferDetailsExample, + account_info: ObpOfferAccountInfoExample, + executions: List[ObpOfferExecutionExample] + ) // ResourceDoc for: GET /obp/v7.0.0/.../trading/offers/{OFFER_ID} def getOfferDoc(offer: OfferService): ResourceDoc = ResourceDoc( partialFunction = getOfferPF(offer), - implementedInApiVersion = "v7.0.0", + implementedInApiVersion = "OBPv7.0.0", partialFunctionName = "getOfferPF", requestVerb = "GET", requestUrl = "/obp/v7.0.0/banks/{BANK_ID}/accounts/{ACCOUNT_ID}/views/{VIEW_ID}/trading/offers/{OFFER_ID}", - summary = "Get OBP trading offer by id", + summary = "Get trading offer by id", description = "Returns the trading offer details by id for the given bank/account/view.", exampleRequestBody = EmptyBody, - successResponseBody = EmptyBody, + successResponseBody = ObpOfferResponseExample( + offer_id = "offer_789", + status = "active", + created_at = "2024-01-15T10:30:00Z", + updated_at = "2024-01-15T10:30:00Z", + offer_details = ObpOfferDetailsExample( + offer_type = "buy", + asset_code = "BTC", + asset_amount = "1.5", + filled_amount = "0.3", + remaining_amount = "1.2", + price_currency = "USD", + price_amount = "45000.00", + expiry_datetime = "2024-12-31T23:59:59Z", + minimum_fill = "0.1" + ), + account_info = ObpOfferAccountInfoExample( + bank_id = "BANK_ID", + account_id = "ACCOUNT_ID", + view_id = "VIEW_ID" + ), + executions = List( + ObpOfferExecutionExample( + execution_id = "exec_123", + executed_amount = "0.3", + executed_price = "45000.00", + executed_at = "2024-01-15T11:00:00Z", + counterpart_offer_id = "offer_456" + ) + ) + ), errorResponseBodies = List("not_found", "bad_request"), tags = List("trading", "offer"), roles = None, @@ -182,12 +245,46 @@ object Routes { } private def productToJson(p: Product): Json = p match { case EmptyBody => Json.Null - case other => Json.fromString(other.toString) + case resp: ObpOfferResponseExample => + Json.obj( + "offer_id" -> Json.fromString(resp.offer_id), + "status" -> Json.fromString(resp.status), + "created_at" -> Json.fromString(resp.created_at), + "updated_at" -> Json.fromString(resp.updated_at), + "offer_details" -> Json.obj( + "offer_type" -> Json.fromString(resp.offer_details.offer_type), + "asset_code" -> Json.fromString(resp.offer_details.asset_code), + "asset_amount" -> Json.fromString(resp.offer_details.asset_amount), + "filled_amount" -> Json.fromString(resp.offer_details.filled_amount), + "remaining_amount" -> Json.fromString(resp.offer_details.remaining_amount), + "price_currency" -> Json.fromString(resp.offer_details.price_currency), + "price_amount" -> Json.fromString(resp.offer_details.price_amount), + "expiry_datetime" -> Json.fromString(resp.offer_details.expiry_datetime), + "minimum_fill" -> Json.fromString(resp.offer_details.minimum_fill) + ), + "account_info" -> Json.obj( + "bank_id" -> Json.fromString(resp.account_info.bank_id), + "account_id"-> Json.fromString(resp.account_info.account_id), + "view_id" -> Json.fromString(resp.account_info.view_id) + ), + "executions" -> Json.arr( + resp.executions.map { exec => + Json.obj( + "execution_id" -> Json.fromString(exec.execution_id), + "executed_amount" -> Json.fromString(exec.executed_amount), + "executed_price" -> Json.fromString(exec.executed_price), + "executed_at" -> Json.fromString(exec.executed_at), + "counterpart_offer_id"-> Json.fromString(exec.counterpart_offer_id) + ) + }: _* + ) + ) + case other => Json.fromString(other.toString) } private def toResourceDocJson(doc: ResourceDoc): ResourceDocJson = ResourceDocJson( - operation_id = doc.partialFunctionName, + operation_id = doc.implementedInApiVersion+"-"+doc.partialFunctionName, implemented_by = ImplementedByJson(doc.implementedInApiVersion, doc.partialFunctionName), request_url = doc.requestUrl, summary = doc.summary, From 71036c2cb37d62081fde2e2f562d30b4186e6536 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 2 Dec 2025 00:38:29 +0100 Subject: [PATCH 14/20] refactor/Streamline JSON serialization for ObpOfferResponseExample by utilizing automatic derivation, improving code readability and maintainability --- .../openbankproject/trading/http/Routes.scala | 54 ++++--------------- 1 file changed, 11 insertions(+), 43 deletions(-) diff --git a/src/main/scala/com/openbankproject/trading/http/Routes.scala b/src/main/scala/com/openbankproject/trading/http/Routes.scala index 167528c..8a6f725 100644 --- a/src/main/scala/com/openbankproject/trading/http/Routes.scala +++ b/src/main/scala/com/openbankproject/trading/http/Routes.scala @@ -1,19 +1,20 @@ package com.openbankproject.trading.http -import org.http4s._ -import org.http4s.dsl.io._ +import cats.effect.IO import cats.syntax.all._ +import com.openbankproject.trading.docs.model.{EmptyBody, ImplementedByJson, ResourceDoc, ResourceDocJson} +import com.openbankproject.trading.docs.registry.ResourceDocRegistry import com.openbankproject.trading.service._ -import org.http4s.circe.CirceEntityCodec._ import io.circe.generic.auto._ import io.circe.parser.parse -import java.util.UUID -import scala.util.Try -import com.openbankproject.trading.docs.model.{ResourceDoc, EmptyBody, ResourceDocJson, ImplementedByJson} -import com.openbankproject.trading.docs.registry.ResourceDocRegistry -import cats.effect.IO import io.circe.syntax._ import io.circe.Json +import org.http4s._ +import org.http4s.circe.CirceEntityCodec._ +import org.http4s.dsl.io._ + +import java.util.UUID +import scala.util.Try /** Aggregated HTTP routes (interfaces only, no concrete wiring). */ object Routes { @@ -245,41 +246,8 @@ object Routes { } private def productToJson(p: Product): Json = p match { case EmptyBody => Json.Null - case resp: ObpOfferResponseExample => - Json.obj( - "offer_id" -> Json.fromString(resp.offer_id), - "status" -> Json.fromString(resp.status), - "created_at" -> Json.fromString(resp.created_at), - "updated_at" -> Json.fromString(resp.updated_at), - "offer_details" -> Json.obj( - "offer_type" -> Json.fromString(resp.offer_details.offer_type), - "asset_code" -> Json.fromString(resp.offer_details.asset_code), - "asset_amount" -> Json.fromString(resp.offer_details.asset_amount), - "filled_amount" -> Json.fromString(resp.offer_details.filled_amount), - "remaining_amount" -> Json.fromString(resp.offer_details.remaining_amount), - "price_currency" -> Json.fromString(resp.offer_details.price_currency), - "price_amount" -> Json.fromString(resp.offer_details.price_amount), - "expiry_datetime" -> Json.fromString(resp.offer_details.expiry_datetime), - "minimum_fill" -> Json.fromString(resp.offer_details.minimum_fill) - ), - "account_info" -> Json.obj( - "bank_id" -> Json.fromString(resp.account_info.bank_id), - "account_id"-> Json.fromString(resp.account_info.account_id), - "view_id" -> Json.fromString(resp.account_info.view_id) - ), - "executions" -> Json.arr( - resp.executions.map { exec => - Json.obj( - "execution_id" -> Json.fromString(exec.execution_id), - "executed_amount" -> Json.fromString(exec.executed_amount), - "executed_price" -> Json.fromString(exec.executed_price), - "executed_at" -> Json.fromString(exec.executed_at), - "counterpart_offer_id"-> Json.fromString(exec.counterpart_offer_id) - ) - }: _* - ) - ) - case other => Json.fromString(other.toString) + case resp: ObpOfferResponseExample => resp.asJson + case other => Json.fromString(other.toString).asJson } private def toResourceDocJson(doc: ResourceDoc): ResourceDocJson = From e0875642da97021479064eae1daf114b5664fe23 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 2 Dec 2025 09:19:19 +0100 Subject: [PATCH 15/20] feature/Add ResourceDocMeta and ResourceDocsJson case classes to enhance API documentation structure and support for metadata in exported resource documents --- .../trading/docs/model/ResourceDoc.scala | 31 +++++++++++++++++-- .../openbankproject/trading/http/Routes.scala | 25 +++++++++++---- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala index 54349db..3dfc020 100644 --- a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala +++ b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala @@ -1,9 +1,8 @@ package com.openbankproject.trading.docs.model -import org.http4s.{Request, Response} -import cats.effect.IO import com.openbankproject.trading.http.OBPEndpoint import io.circe.Json +import java.time.Instant /** * ResourceDoc aligned with OBP-API structure. @@ -56,9 +55,27 @@ final case class ImplementedByJson( /** * Export-friendly ResourceDocJson, mirroring OBP-API structure for docs/export. */ + +/** + * Metadata summary for exported ResourceDocs. + */ +final case class ResourceDocMeta( + response_date: Instant, + count: Int +) + +/** + * Wrapper for exported docs list and metadata. + */ +final case class ResourceDocsJson( + resource_docs: List[ResourceDocJson], + meta: Option[ResourceDocMeta] = None +) + final case class ResourceDocJson( operation_id: String, implemented_by: ImplementedByJson, + request_verb: String, request_url: String, summary: String, description: String, @@ -86,8 +103,18 @@ object ResourceDocJson { implicit val implementedByJsonDecoder: Decoder[ImplementedByJson] = deriveDecoder[ImplementedByJson] + implicit val resourceDocMetaEncoder: Encoder[ResourceDocMeta] = + deriveEncoder[ResourceDocMeta] + implicit val resourceDocMetaDecoder: Decoder[ResourceDocMeta] = + deriveDecoder[ResourceDocMeta] + implicit val resourceDocJsonEncoder: Encoder[ResourceDocJson] = deriveEncoder[ResourceDocJson] implicit val resourceDocJsonDecoder: Decoder[ResourceDocJson] = deriveDecoder[ResourceDocJson] + + implicit val resourceDocsJsonEncoder: Encoder[ResourceDocsJson] = + deriveEncoder[ResourceDocsJson] + implicit val resourceDocsJsonDecoder: Decoder[ResourceDocsJson] = + deriveDecoder[ResourceDocsJson] } diff --git a/src/main/scala/com/openbankproject/trading/http/Routes.scala b/src/main/scala/com/openbankproject/trading/http/Routes.scala index 8a6f725..533e223 100644 --- a/src/main/scala/com/openbankproject/trading/http/Routes.scala +++ b/src/main/scala/com/openbankproject/trading/http/Routes.scala @@ -2,13 +2,14 @@ package com.openbankproject.trading.http import cats.effect.IO import cats.syntax.all._ -import com.openbankproject.trading.docs.model.{EmptyBody, ImplementedByJson, ResourceDoc, ResourceDocJson} +import com.openbankproject.trading.docs.model.{EmptyBody, ImplementedByJson, ResourceDoc, ResourceDocJson, ResourceDocMeta, ResourceDocsJson} import com.openbankproject.trading.docs.registry.ResourceDocRegistry import com.openbankproject.trading.service._ import io.circe.generic.auto._ import io.circe.parser.parse import io.circe.syntax._ import io.circe.Json +import java.time.Instant import org.http4s._ import org.http4s.circe.CirceEntityCodec._ import org.http4s.dsl.io._ @@ -180,7 +181,7 @@ object Routes { implementedInApiVersion = "OBPv7.0.0", partialFunctionName = "getOfferPF", requestVerb = "GET", - requestUrl = "/obp/v7.0.0/banks/{BANK_ID}/accounts/{ACCOUNT_ID}/views/{VIEW_ID}/trading/offers/{OFFER_ID}", + requestUrl = "/obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID", summary = "Get trading offer by id", description = "Returns the trading offer details by id for the given bank/account/view.", exampleRequestBody = EmptyBody, @@ -247,13 +248,14 @@ object Routes { private def productToJson(p: Product): Json = p match { case EmptyBody => Json.Null case resp: ObpOfferResponseExample => resp.asJson - case other => Json.fromString(other.toString).asJson + case other => Json.fromString(other.toString) } private def toResourceDocJson(doc: ResourceDoc): ResourceDocJson = ResourceDocJson( - operation_id = doc.implementedInApiVersion+"-"+doc.partialFunctionName, + operation_id = doc.implementedInApiVersion + "-" + doc.partialFunctionName, implemented_by = ImplementedByJson(doc.implementedInApiVersion, doc.partialFunctionName), + request_verb = doc.requestVerb, request_url = doc.requestUrl, summary = doc.summary, description = doc.description, @@ -272,11 +274,22 @@ object Routes { created_by_bank_id = doc.createdByBankId ) + private def normalizeVersion(apiVersion: String): String = { + val upper = apiVersion.toUpperCase + if (upper.startsWith("OBP")) upper else s"OBP$upper" + } + def getResourceDocsPF: OBPEndpoint = { { case GET -> Root / "obp" / "v7.0.0" / "resource-docs" / apiVersion / "obp" => - val docs = ResourceDocRegistry.all.map(toResourceDocJson) - Ok(docs.asJson) + val normalized = normalizeVersion(apiVersion) + val docs = ResourceDocRegistry.all + .filter(_.implementedInApiVersion.equalsIgnoreCase(normalized)) + .map(toResourceDocJson) + .toList + val meta = ResourceDocMeta(response_date = Instant.now(), count = docs.size) + val payload = ResourceDocsJson(resource_docs = docs, meta = Some(meta)) + Ok(payload.asJson) } } From 5756c2cfdd9b8995b327b4a2135945f057b95f20 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 2 Dec 2025 11:06:39 +0100 Subject: [PATCH 16/20] feature/Add JSON Schema support for API responses by integrating scala-jsonschema library and defining schemas for offer-related examples --- build.sbt | 5 ++++ .../openbankproject/trading/http/Routes.scala | 25 ++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/build.sbt b/build.sbt index 80fc28a..b21383e 100644 --- a/build.sbt +++ b/build.sbt @@ -43,6 +43,11 @@ libraryDependencies ++= Seq( "io.circe" %% "circe-generic" % "0.14.6", "io.circe" %% "circe-parser" % "0.14.6", "io.circe" %% "circe-literal" % "0.14.6", + + // JSON Schema generation + "io.github.andyglow" %% "scala-jsonschema" % "0.7.11", + "io.github.andyglow" %% "scala-jsonschema-circe-json" % "0.7.11", + // Database "com.typesafe.slick" %% "slick" % slickVersion, diff --git a/src/main/scala/com/openbankproject/trading/http/Routes.scala b/src/main/scala/com/openbankproject/trading/http/Routes.scala index 533e223..cca380e 100644 --- a/src/main/scala/com/openbankproject/trading/http/Routes.scala +++ b/src/main/scala/com/openbankproject/trading/http/Routes.scala @@ -2,6 +2,9 @@ package com.openbankproject.trading.http import cats.effect.IO import cats.syntax.all._ +import com.github.andyglow.jsonschema.AsCirce._ +import json.{Json => SchemaJson, _} +import json.schema.Version.Draft07 import com.openbankproject.trading.docs.model.{EmptyBody, ImplementedByJson, ResourceDoc, ResourceDocJson, ResourceDocMeta, ResourceDocsJson} import com.openbankproject.trading.docs.registry.ResourceDocRegistry import com.openbankproject.trading.service._ @@ -9,11 +12,11 @@ import io.circe.generic.auto._ import io.circe.parser.parse import io.circe.syntax._ import io.circe.Json -import java.time.Instant import org.http4s._ import org.http4s.circe.CirceEntityCodec._ import org.http4s.dsl.io._ +import java.time.Instant import java.util.UUID import scala.util.Try @@ -150,6 +153,14 @@ object Routes { executed_at: String, counterpart_offer_id: String ) + private implicit val obpOfferExecutionSchema: Schema[ObpOfferExecutionExample] = + SchemaJson.schema[ObpOfferExecutionExample] + private implicit val obpOfferDetailsSchema: Schema[ObpOfferDetailsExample] = + SchemaJson.schema[ObpOfferDetailsExample] + private implicit val obpOfferAccountSchema: Schema[ObpOfferAccountInfoExample] = + SchemaJson.schema[ObpOfferAccountInfoExample] + private implicit val obpOfferResponseSchema: Schema[ObpOfferResponseExample] = + SchemaJson.schema[ObpOfferResponseExample] private final case class ObpOfferDetailsExample( offer_type: String, asset_code: String, @@ -175,6 +186,14 @@ object Routes { account_info: ObpOfferAccountInfoExample, executions: List[ObpOfferExecutionExample] ) + private val draft07 = Draft07("http://json-schema.org/draft-07/schema#") + private def schemaOf[T: Schema]: Json = + SchemaJson.schema[T].asCirce(draft07) + private def schemaFromProduct(p: Product): Option[Json] = p match { + case EmptyBody => None + case _: ObpOfferResponseExample => Some(schemaOf[ObpOfferResponseExample]) + case _ => None + } // ResourceDoc for: GET /obp/v7.0.0/.../trading/offers/{OFFER_ID} def getOfferDoc(offer: OfferService): ResourceDoc = ResourceDoc( partialFunction = getOfferPF(offer), @@ -264,8 +283,8 @@ object Routes { success_response_body = productToJson(doc.successResponseBody), error_response_bodies = doc.errorResponseBodies, tags = doc.tags, - typed_request_body = Json.Null, - typed_success_response_body = Json.Null, + typed_request_body = schemaFromProduct(doc.exampleRequestBody).getOrElse(Json.Null), + typed_success_response_body = schemaFromProduct(doc.successResponseBody).getOrElse(Json.Null), roles = doc.roles, is_featured = doc.isFeatured, special_instructions = doc.specialInstructions, From 338c0a74df1ebd63a1a5a0ab5582f96492fa45f8 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 2 Dec 2025 11:53:34 +0100 Subject: [PATCH 17/20] refactor/Update ResourceDoc and Routes to use Option types for exampleRequestBody and specifiedUrl, improving API documentation clarity and handling of optional values --- .../trading/docs/model/ResourceDoc.scala | 12 ++++++------ .../openbankproject/trading/http/Routes.scala | 19 +++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala index 3dfc020..37c417a 100644 --- a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala +++ b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala @@ -29,14 +29,14 @@ final case class ResourceDoc( requestUrl: String, summary: String, description: String, - exampleRequestBody: Product, + exampleRequestBody: Option[Product] = None, successResponseBody: Product, errorResponseBodies: List[String], tags: List[String], roles: Option[List[String]] = None, isFeatured: Boolean = false, specialInstructions: Option[String] = None, - specifiedUrl: Option[String] = None, + specifiedUrl: String = "", createdByBankId: Option[String] = None ) { require(partialFunctionName.trim.nonEmpty, "partialFunctionName must be non-empty") @@ -80,16 +80,16 @@ final case class ResourceDocJson( summary: String, description: String, description_markdown: String, - example_request_body: Json, + example_request_body: Option[Json], success_response_body: Json, error_response_bodies: List[String], tags: List[String], - typed_request_body: Json, - typed_success_response_body: Json, + typed_request_body: Option[Json], + typed_success_response_body: Option[Json], roles: Option[List[String]] = None, is_featured: Boolean = false, special_instructions: Option[String] = None, - specified_url: Option[String] = None, + specified_url: String, connector_methods: List[String] = Nil, created_by_bank_id: Option[String] = None ) diff --git a/src/main/scala/com/openbankproject/trading/http/Routes.scala b/src/main/scala/com/openbankproject/trading/http/Routes.scala index cca380e..ce74168 100644 --- a/src/main/scala/com/openbankproject/trading/http/Routes.scala +++ b/src/main/scala/com/openbankproject/trading/http/Routes.scala @@ -189,11 +189,10 @@ object Routes { private val draft07 = Draft07("http://json-schema.org/draft-07/schema#") private def schemaOf[T: Schema]: Json = SchemaJson.schema[T].asCirce(draft07) - private def schemaFromProduct(p: Product): Option[Json] = p match { - case EmptyBody => None - case _: ObpOfferResponseExample => Some(schemaOf[ObpOfferResponseExample]) - case _ => None - } + private def schemaFromProduct(p: Option[Product]): Option[Json] = + p.collect { + case _: ObpOfferResponseExample => schemaOf[ObpOfferResponseExample] + } // ResourceDoc for: GET /obp/v7.0.0/.../trading/offers/{OFFER_ID} def getOfferDoc(offer: OfferService): ResourceDoc = ResourceDoc( partialFunction = getOfferPF(offer), @@ -203,7 +202,7 @@ object Routes { requestUrl = "/obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID", summary = "Get trading offer by id", description = "Returns the trading offer details by id for the given bank/account/view.", - exampleRequestBody = EmptyBody, + exampleRequestBody = None, successResponseBody = ObpOfferResponseExample( offer_id = "offer_789", status = "active", @@ -240,7 +239,7 @@ object Routes { roles = None, isFeatured = false, specialInstructions = None, - specifiedUrl = None, + specifiedUrl = "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID", createdByBankId = None ) @@ -279,12 +278,12 @@ object Routes { summary = doc.summary, description = doc.description, description_markdown = doc.description, - example_request_body = productToJson(doc.exampleRequestBody), + example_request_body = doc.exampleRequestBody.map(productToJson), success_response_body = productToJson(doc.successResponseBody), error_response_bodies = doc.errorResponseBodies, tags = doc.tags, - typed_request_body = schemaFromProduct(doc.exampleRequestBody).getOrElse(Json.Null), - typed_success_response_body = schemaFromProduct(doc.successResponseBody).getOrElse(Json.Null), + typed_request_body = schemaFromProduct(doc.exampleRequestBody), + typed_success_response_body = schemaFromProduct(Some(doc.successResponseBody)), roles = doc.roles, is_featured = doc.isFeatured, special_instructions = doc.specialInstructions, From 406d4331997ec6959eccf695c35ee15296853c83 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 2 Dec 2025 13:43:34 +0100 Subject: [PATCH 18/20] refactor/Enhance JSON encoding for ResourceDocJson by dropping null values, improving API response clarity and reducing payload size --- .../com/openbankproject/trading/docs/model/ResourceDoc.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala index 37c417a..8dd480c 100644 --- a/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala +++ b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala @@ -109,7 +109,7 @@ object ResourceDocJson { deriveDecoder[ResourceDocMeta] implicit val resourceDocJsonEncoder: Encoder[ResourceDocJson] = - deriveEncoder[ResourceDocJson] + deriveEncoder[ResourceDocJson].mapJson(_.dropNullValues) implicit val resourceDocJsonDecoder: Decoder[ResourceDocJson] = deriveDecoder[ResourceDocJson] From d245013ae290b36dcf536bb2257ab24fe5cceae0 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 2 Dec 2025 14:20:28 +0100 Subject: [PATCH 19/20] fix/Update library dependencies for JSON schema integration to use the correct GitHub organization for scala-jsonschema --- build.sbt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.sbt b/build.sbt index b21383e..c07d4ec 100644 --- a/build.sbt +++ b/build.sbt @@ -45,8 +45,8 @@ libraryDependencies ++= Seq( "io.circe" %% "circe-literal" % "0.14.6", // JSON Schema generation - "io.github.andyglow" %% "scala-jsonschema" % "0.7.11", - "io.github.andyglow" %% "scala-jsonschema-circe-json" % "0.7.11", + "com.github.andyglow" %% "scala-jsonschema" % "0.7.11", + "com.github.andyglow" %% "scala-jsonschema-circe-json" % "0.7.11", // Database From 33293e072a5768d9c08962893a2bce7da35874d3 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 2 Dec 2025 14:45:49 +0100 Subject: [PATCH 20/20] docs/Add deployment instructions to README for building and running the assembly JAR --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 8b04df9..7cc61b8 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,20 @@ connectors { See `ai.log` for detailed development history and architectural decisions. + +## Deployment + +1. Build the assembly JAR: + ```bash + sbt assembly + ``` +2. Copy the JAR from `target/scala-2.13/obp-trading-assembly-0.1.0-SNAPSHOT.jar` to your server. +3. Run with Java 11+: + ```bash + java -jar obp-trading-assembly-0.1.0-SNAPSHOT.jar + ``` + + ## License AGPL-3.0