diff --git a/.gitignore b/.gitignore index ec8dc9f..8db5b53 100644 --- a/.gitignore +++ b/.gitignore @@ -139,6 +139,6 @@ build/ *.code-workspace .specstory -project/project/metals.sbt -project/metals.sbt +metals.sbt .bsp/sbt.json +*.conf 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 diff --git a/build.sbt b/build.sbt index 80fc28a..c07d4ec 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 + "com.github.andyglow" %% "scala-jsonschema" % "0.7.11", + "com.github.andyglow" %% "scala-jsonschema-circe-json" % "0.7.11", + // Database "com.typesafe.slick" %% "slick" % slickVersion, 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/docs/TradingResourceDocs.scala b/src/main/scala/com/openbankproject/trading/docs/TradingResourceDocs.scala new file mode 100644 index 0000000..8c38d14 --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/docs/TradingResourceDocs.scala @@ -0,0 +1,13 @@ +package com.openbankproject.trading.docs + +import com.openbankproject.trading.docs.model.ResourceDoc +import com.openbankproject.trading.docs.registry.ResourceDocRegistry + +object TradingResourceDocs { + + private val docs: Seq[ResourceDoc] = 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/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..8dd480c --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/docs/model/ResourceDoc.scala @@ -0,0 +1,120 @@ +package com.openbankproject.trading.docs.model + +import com.openbankproject.trading.http.OBPEndpoint +import io.circe.Json +import java.time.Instant + +/** + * 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., "getOfferPF") + * @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( + partialFunction: OBPEndpoint, + implementedInApiVersion: String, + partialFunctionName: String, + requestVerb: String, + requestUrl: String, + summary: String, + description: String, + 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: String = "", + createdByBankId: Option[String] = None +) { + 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") +} + +/** + * 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. "getOfferPF" +) + +/** + * 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, + description_markdown: String, + example_request_body: Option[Json], + success_response_body: Json, + error_response_bodies: List[String], + tags: List[String], + 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: String, + 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 resourceDocMetaEncoder: Encoder[ResourceDocMeta] = + deriveEncoder[ResourceDocMeta] + implicit val resourceDocMetaDecoder: Decoder[ResourceDocMeta] = + deriveDecoder[ResourceDocMeta] + + implicit val resourceDocJsonEncoder: Encoder[ResourceDocJson] = + deriveEncoder[ResourceDocJson].mapJson(_.dropNullValues) + 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/docs/registry/ConsistencyCheck.scala b/src/main/scala/com/openbankproject/trading/docs/registry/ConsistencyCheck.scala new file mode 100644 index 0000000..3494488 --- /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 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 new file mode 100644 index 0000000..81714b0 --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/docs/registry/ResourceDocRegistry.scala @@ -0,0 +1,25 @@ +package com.openbankproject.trading.docs.registry + +import com.openbankproject.trading.docs.model.ResourceDoc +import scala.collection.concurrent.TrieMap + +object ResourceDocRegistry { + private[this] val byPartialFunctionName: TrieMap[String, ResourceDoc] = TrieMap.empty + + def register(doc: ResourceDoc): Unit = { + byPartialFunctionName.put(doc.partialFunctionName, doc) + () + } + + def registerAll(docs: Iterable[ResourceDoc]): Unit = docs.foreach(register) + + def get(partialFunctionName: String): Option[ResourceDoc] = + byPartialFunctionName.get(partialFunctionName) + + 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 645a6ba..4215a82 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. @@ -13,31 +14,38 @@ import com.comcast.ip4s._ */ object HttpServerMain extends IOApp.Simple { - private val orderServiceIO: IO[OrderService[IO]] = InMemoryOrderService.create[IO]() + private val orderServiceIO: IO[OrderService] = InMemoryOrderService.create() + private val offerServiceIO: IO[OfferService] = InMemoryOfferService.create() - private val matchService: MatchService[IO] = new MatchService[IO] { - def createMatch(req: MatchRequest) = IO.pure(Left(ErrorResponse("not_implemented", "createMatch not implemented"))) + 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] { - 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"))) + 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] { - 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"))) + 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"))) } 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, 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")) _ <- EmberServerBuilder .default[IO] - .withHost(ipv4"0.0.0.0") - .withPort(port"8080") + .withHost(host) + .withPort(port) .withHttpApp(httpApp) .build .useForever diff --git a/src/main/scala/com/openbankproject/trading/http/Routes.scala b/src/main/scala/com/openbankproject/trading/http/Routes.scala index b5c997f..ce74168 100644 --- a/src/main/scala/com/openbankproject/trading/http/Routes.scala +++ b/src/main/scala/com/openbankproject/trading/http/Routes.scala @@ -1,129 +1,342 @@ package com.openbankproject.trading.http -import org.http4s._ -import org.http4s.dsl.Http4sDsl -import cats.effect.Async +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._ -import org.http4s.circe.CirceEntityCodec._ import io.circe.generic.auto._ import io.circe.parser.parse +import io.circe.syntax._ +import io.circe.Json +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 /** Aggregated HTTP routes (interfaces only, no concrete wiring). */ object Routes { - def api[F[_]: Async]( - order: OrderService[F], - matcher: MatchService[F], - settlement: SettlementService[F], - funds: FundsService[F] - ): HttpRoutes[F] = { - val dsl = new Http4sDsl[F] {}; import dsl._ - - HttpRoutes.of[F] { - // ========== Minimal market endpoints (internal shape) ========== + // ===== Named partial functions for OBP Offer endpoints ===== + 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 CreateOfferReq( + 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[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 { + 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) + } + } + } + } + } + + // ===== Named partial functions for Minimal Market endpoints ===== + def postMarketOrdersPF(order: OrderService): OBPEndpoint = { + { // 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) case Left(err) => BadRequest(err) } } + } + } + def deleteMarketOrderPF(order: OrderService): OBPEndpoint = { + { // 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(order: OrderService): OBPEndpoint = { + { // 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(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(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(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(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(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)) + } + } - // ========== OBP-style Offer endpoints (map to OrderService) ========== - // 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 + // 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 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, + 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] + ) + 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: 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), + 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 trading offer by id", + description = "Returns the trading offer details by id for the given bank/account/view.", + exampleRequestBody = None, + 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" ) - 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 = CreateOrderRequest( - side = side, - price = price, - quantity = qty, - accountId = acct, - idempotencyKey = idKey - ) - order.createOrder(req0).flatMap { - case Right(ok) => Created(ok) - case Left(err) => BadRequest(err) - } - } - } + ) + ), + errorResponseBodies = List("not_found", "bad_request"), + tags = List("trading", "offer"), + roles = None, + isFeatured = false, + specialInstructions = None, + specifiedUrl = "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID", + createdByBankId = None + ) - // GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID + def getOfferPF(offer: OfferService): OBPEndpoint = { + { 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) } + } + } + 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 => - order.cancelOrder(offerId).flatMap { + offer.cancelOffer(offerId).flatMap { case Right(ok) => Ok(ok) case Left(err) => BadRequest(err) } } } + private def productToJson(p: Product): Json = p match { + case EmptyBody => Json.Null + case resp: ObpOfferResponseExample => resp.asJson + case other => Json.fromString(other.toString) + } + + private def toResourceDocJson(doc: ResourceDoc): ResourceDocJson = + ResourceDocJson( + 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, + description_markdown = doc.description, + 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), + typed_success_response_body = schemaFromProduct(Some(doc.successResponseBody)), + roles = doc.roles, + is_featured = doc.isFeatured, + special_instructions = doc.specialInstructions, + specified_url = doc.specifiedUrl, + connector_methods = Nil, + 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 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) + } + } + + def api( + order: OrderService, + offer: OfferService, + matcher: MatchService, + settlement: SettlementService, + funds: FundsService + ): HttpRoutes[IO] = { + ResourceDocRegistry.register(getOfferDoc(offer)) + val 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 offerPF = + createOfferPF(offer) + .orElse(getOfferPF(offer)) + .orElse(cancelOfferPF(offer)) + .orElse(getResourceDocsPF) + val offerRoutes: HttpRoutes[IO] = HttpRoutes.of[IO](offerPF) + marketRoutes <+> offerRoutes + } } 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/http/package.scala b/src/main/scala/com/openbankproject/trading/http/package.scala new file mode 100644 index 0000000..8a7b5b9 --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/http/package.scala @@ -0,0 +1,54 @@ +package com.openbankproject.trading + +import org.http4s.{Request, Response} +import cats.effect.IO + +/** + * 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 = PartialFunction[Request[IO], IO[Response[IO]]] + + // ========== 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" + } +} + 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..ebb55d5 --- /dev/null +++ b/src/main/scala/com/openbankproject/trading/service/InMemoryOfferService.scala @@ -0,0 +1,85 @@ +package com.openbankproject.trading.service + +import cats.effect.IO +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 + +final class InMemoryOfferService private ( + state: Ref[IO, Map[String, InMemoryOfferService.StoredOffer]] +) extends OfferService { + + import InMemoryOfferService.StoredOffer + + def createOffer(req: CreateOfferRequest): IO[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)) *> IO.pure(Right(CreateOfferResponse(id, stored.status, stored.remaining))) + } + + def cancelOffer(offerId: String): IO[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(ErrorCodes.NOT_FOUND, s"Offer $offerId not found"))) + } + } + } + + def getOffer(offerId: String): IO[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(ErrorCodes.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(): 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 996840b..03fb84c 100644 --- a/src/main/scala/com/openbankproject/trading/service/InMemoryOrderService.scala +++ b/src/main/scala/com/openbankproject/trading/service/InMemoryOrderService.scala @@ -1,20 +1,21 @@ 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._ +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( @@ -28,22 +29,22 @@ 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) => 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"))) } } } - 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) => @@ -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")) } } } @@ -77,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 402a4b7..7e6abd0 100644 --- a/src/main/scala/com/openbankproject/trading/service/Services.scala +++ b/src/main/scala/com/openbankproject/trading/service/Services.scala @@ -1,25 +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 MatchService[F[_]] { - def createMatch(req: MatchRequest): F[Either[ErrorResponse, CreateMatchResponse]] +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 SettlementService[F[_]] { - def settle(req: SettlementRequest): F[Either[ErrorResponse, SettlementResponse]] - def getTrade(tradeId: String): F[Either[ErrorResponse, TradeView]] +trait MatchService { + def createMatch(req: MatchRequest): IO[Either[ErrorResponse, CreateMatchResponse]] } -trait FundsService[F[_]] { - def notifyDeposit(req: DepositNotification): F[Either[ErrorResponse, DepositResponse]] - def requestWithdrawal(req: WithdrawalRequest): F[Either[ErrorResponse, WithdrawalResponse]] +trait SettlementService { + def settle(req: SettlementRequest): IO[Either[ErrorResponse, SettlementResponse]] + def getTrade(tradeId: String): IO[Either[ErrorResponse, TradeView]] +} + +trait FundsService { + def notifyDeposit(req: DepositNotification): IO[Either[ErrorResponse, DepositResponse]] + def requestWithdrawal(req: WithdrawalRequest): IO[Either[ErrorResponse, WithdrawalResponse]] } 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..accf10b --- /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) = { + 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 = new MatchService { + def createMatch(req: MatchRequest) = IO.pure(Left(ErrorResponse("not_implemented", "match not used in this test"))) + } + 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 = 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(orderStub, offerService, matchStub, settlementStub, fundsStub).orNotFound + } + + test("create -> get -> cancel offer happy path") { + val service = InMemoryOfferService.create().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().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().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 + } +} + +