diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 09:27:09 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 09:27:09 +0800 |
| commit | a5ac465ab59197a55a222d30ebd2cf26ffe80f86 (patch) | |
| tree | 8ea3696897f99878545f3e7c7aa1e7249b95b81b /src/FundLab.Api/App.fs | |
| parent | 0aad89fc76aa77524625964ebad896a6bc0d3660 (diff) | |
| download | fund-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/App.fs')
| -rw-r--r-- | src/FundLab.Api/App.fs | 192 |
1 files changed, 192 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 []) |
