summaryrefslogtreecommitdiff
path: root/src/FundLab.Api
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 09:27:09 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 09:27:09 +0800
commita5ac465ab59197a55a222d30ebd2cf26ffe80f86 (patch)
tree8ea3696897f99878545f3e7c7aa1e7249b95b81b /src/FundLab.Api
parent0aad89fc76aa77524625964ebad896a6bc0d3660 (diff)
downloadfund-lab-a5ac465ab59197a55a222d30ebd2cf26ffe80f86.tar.gz
Fix F# option JSON serialization in confirmation/positions API responses
Giraffe 6's default Newtonsoft serializer wrapped F# Some values as Value/IsSome/IsNone objects, breaking the confirmation and positions response contract (amounts must be exact decimal strings, absent fields null, quote object-or-null). Add OptionJsonConverter plus a shared serializer registration applied to both the Kestrel host and the in-process test fixture, and cover confirmation/positions over real HTTP including pending, conflict, cross-fund and Kestrel restart-replay regressions.
Diffstat (limited to 'src/FundLab.Api')
-rw-r--r--src/FundLab.Api/App.fs192
-rw-r--r--src/FundLab.Api/Program.fs1
2 files changed, 193 insertions, 0 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index a2aa271..839a641 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -6,6 +6,37 @@ open System.IO
open System.Text.Json
open Giraffe
open Microsoft.AspNetCore.Http
+open Microsoft.Extensions.DependencyInjection
+open Microsoft.FSharp.Reflection
+
+type OptionJsonConverter() =
+ inherit Newtonsoft.Json.JsonConverter()
+
+ override _.CanConvert(objectType: Type) =
+ objectType.IsGenericType
+ && objectType.GetGenericTypeDefinition() = typedefof<option<_>>
+
+ override _.WriteJson(writer: Newtonsoft.Json.JsonWriter, value: obj, serializer: Newtonsoft.Json.JsonSerializer) =
+ if isNull value then
+ writer.WriteNull()
+ else
+ let _, fields = FSharpValue.GetUnionFields(value, value.GetType())
+
+ match fields with
+ | [| inner |] -> serializer.Serialize(writer, inner)
+ | _ -> writer.WriteNull()
+
+ override _.ReadJson(reader: Newtonsoft.Json.JsonReader, objectType: Type, existingValue: obj, serializer: Newtonsoft.Json.JsonSerializer) =
+ let cases = FSharpType.GetUnionCases objectType
+ let innerType = objectType.GetGenericArguments().[0]
+
+ if reader.TokenType = Newtonsoft.Json.JsonToken.Null then
+ FSharpValue.MakeUnion(cases.[0], [||])
+ else
+ let inner = serializer.Deserialize(reader, innerType)
+ let someCase = cases |> Array.find (fun case -> case.Name = "Some")
+ FSharpValue.MakeUnion(someCase, [| inner |])
+
type EmptyPortfolioResponse =
{
@@ -39,6 +70,60 @@ type SubscriptionOrderApiResponse =
isSynthetic: bool
}
+type ConfirmedQuoteEvidenceResponse =
+ {
+ navDate: string
+ nav: string
+ source: string
+ sourceRevision: string
+ sourceCollectedAt: string
+ publishedAt: string option
+ sourcePayloadHash: string
+ firstSeenAt: string
+ }
+
+type SubscriptionOrderDetailResponse =
+ {
+ id: Guid
+ fundId: Guid
+ fundCode: string
+ amount: string
+ feeAmount: string
+ reservedTotal: string
+ status: string
+ submittedAt: string
+ tradeDate: string
+ confirmIdempotencyKey: string option
+ pendingReason: string option
+ confirmedAt: string option
+ confirmedNav: string option
+ confirmedNavDate: string option
+ confirmedUnits: string option
+ confirmedInvestedCash: string option
+ confirmedResidualCash: string option
+ quote: ConfirmedQuoteEvidenceResponse option
+ isSynthetic: bool
+ }
+
+type FundPositionResponse =
+ {
+ instrumentCode: string
+ units: string
+ costCash: string
+ lastConfirmedAt: string
+ valuationNav: string option
+ valuationNavDate: string option
+ valuationCollectedAt: string option
+ }
+
+type FundPositionsResponse =
+ {
+ fundId: Guid
+ availableCash: string
+ reservedCash: string
+ positions: FundPositionResponse list
+ }
+
type ApiErrorResponse =
{
error: string
@@ -83,6 +168,15 @@ type MarketDataNavApiResponse =
}
module App =
+ let addOptionFriendlyJson (services: IServiceCollection) =
+ let settings =
+ Newtonsoft.Json.JsonSerializerSettings(
+ ContractResolver = Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
+ )
+
+ settings.Converters.Add(OptionJsonConverter())
+ services.AddSingleton<Giraffe.Json.ISerializer>(NewtonsoftJson.Serializer settings)
+
let private invariant = CultureInfo.InvariantCulture
let private cashText (value: decimal) = value.ToString("0.00", invariant)
@@ -117,6 +211,43 @@ module App =
isSynthetic = order.IsSynthetic
}
+ let private confirmationResponse (order: SubscriptionOrderRecord) : SubscriptionOrderDetailResponse =
+ let quoteEvidence =
+ order.ConfirmedQuote
+ |> Option.map (fun quote ->
+ {
+ navDate = dateText quote.NavDate
+ nav = decimalText quote.Nav
+ source = quote.Source
+ sourceRevision = quote.Revision
+ sourceCollectedAt = timestampText quote.CollectedAt
+ publishedAt = quote.PublishedAt |> Option.map timestampText
+ sourcePayloadHash = quote.PayloadHash
+ firstSeenAt = timestampText quote.FirstSeenAt
+ })
+
+ {
+ id = order.Id
+ fundId = order.FundId
+ fundCode = order.FundCode
+ amount = cashText order.Amount
+ feeAmount = cashText order.FeeAmount
+ reservedTotal = cashText order.ReservedTotal
+ status = order.Status
+ submittedAt = timestampText order.SubmittedAt
+ tradeDate = dateText order.TradeDate
+ confirmIdempotencyKey = order.ConfirmIdempotencyKey
+ pendingReason = order.PendingReason
+ confirmedAt = order.ConfirmedAt |> Option.map timestampText
+ confirmedNav = order.ConfirmedQuote |> Option.map (fun quote -> decimalText quote.Nav)
+ confirmedNavDate = order.ConfirmedQuote |> Option.map (fun quote -> dateText quote.NavDate)
+ confirmedUnits = order.ConfirmedUnits |> Option.map decimalText
+ confirmedInvestedCash = order.ConfirmedInvestedCash |> Option.map cashText
+ confirmedResidualCash = order.ConfirmedResidualCash |> Option.map cashText
+ quote = quoteEvidence
+ isSynthetic = order.IsSynthetic
+ }
+
let private errorResponse status error message : HttpHandler =
setStatusCode status
>=> json ({
@@ -317,6 +448,65 @@ module App =
with _ ->
errorResponse 500 "PERSISTENCE_ERROR" "order persistence failed" next ctx
+ let private confirmOrder (repository: FundRepository) (fundIdText: string) (orderIdText: string) : HttpHandler =
+ fun next ctx ->
+ match Guid.TryParse fundIdText, Guid.TryParse orderIdText with
+ | (false, _), _
+ | _, (false, _) ->
+ errorResponse 400 "INVALID_CONFIRM_REQUEST" "fund id and order id must be UUIDs" next ctx
+ | (true, fundId), (true, orderId) ->
+ let idempotencyKey = ctx.Request.Headers["Idempotency-Key"].ToString()
+
+ try
+ match repository.ConfirmSubscriptionOrder(idempotencyKey, fundId, orderId) with
+ | OrderConfirmed order
+ | ConfirmReplayed order
+ | ConfirmPendingNav order -> json (confirmationResponse order) next ctx
+ | ConfirmIdempotencyConflict ->
+ errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request" next ctx
+ | ConfirmAlreadyConfirmed ->
+ errorResponse 409 "ORDER_ALREADY_CONFIRMED" "order was already confirmed with a different idempotency key" next ctx
+ | ConfirmOrderNotFound -> errorResponse 404 "ORDER_NOT_FOUND" "order was not found" next ctx
+ | ConfirmInvalidStatus ->
+ errorResponse 409 "ORDER_INVALID_STATUS" "order is not in a confirmable status" next ctx
+ | ConfirmInvalid message -> errorResponse 400 "INVALID_CONFIRM_REQUEST" message next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "order confirmation failed" next ctx
+
+ let private getPositions (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ match Guid.TryParse fundIdText with
+ | false, _ -> errorResponse 400 "INVALID_FUND_ID" "fund id must be a UUID" next ctx
+ | true, fundId ->
+ try
+ match repository.GetFund fundId with
+ | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx
+ | Some fund ->
+ let positions =
+ repository.GetFundPositions fundId
+ |> List.map (fun position ->
+ {
+ instrumentCode = position.InstrumentCode
+ units = decimalText position.Units
+ costCash = cashText position.CostCash
+ lastConfirmedAt = timestampText position.LastConfirmedAt
+ valuationNav = position.ValuationNav |> Option.map decimalText
+ valuationNavDate = position.ValuationNavDate |> Option.map dateText
+ valuationCollectedAt = position.ValuationCollectedAt |> Option.map timestampText
+ })
+
+ json
+ ({
+ fundId = fund.Id
+ availableCash = cashText fund.AvailableCash
+ reservedCash = cashText fund.ReservedCash
+ positions = positions
+ } : FundPositionsResponse)
+ next
+ ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "position persistence failed" next ctx
+
let private marketDataError (failure: MarketDataFailure) : HttpHandler =
let status, error, message =
match failure with
@@ -414,6 +604,8 @@ module App =
POST >=> route "/funds" >=> createFund repository
POST >=> routef "/funds/%s/orders" (createOrder repository)
GET >=> routef "/funds/%s/orders" (getOrders repository)
+ POST >=> routef "/funds/%s/orders/%s/confirm" (fun (fundId, orderId) -> confirmOrder repository fundId orderId)
+ GET >=> routef "/funds/%s/positions" (getPositions repository)
GET >=> routef "/funds/%s" (getFund repository)
]
@ (marketData |> Option.map marketDataRoutes |> Option.defaultValue [])
diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs
index d4f2c76..b7d0ef6 100644
--- a/src/FundLab.Api/Program.fs
+++ b/src/FundLab.Api/Program.fs
@@ -13,6 +13,7 @@ let private requiredEnvironment name =
[<EntryPoint>]
let main argv =
let builder = WebApplication.CreateBuilder(argv)
+ builder.Services |> App.addOptionFriendlyJson |> ignore
builder.Services.AddGiraffe() |> ignore
let repository = FundRepository(requiredEnvironment "FUND_LAB_DATABASE_URL")
repository.EnsureSchema()