summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/App.fs
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Api/App.fs')
-rw-r--r--src/FundLab.Api/App.fs148
1 files changed, 143 insertions, 5 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 44628ab..0eb367d 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -1,6 +1,9 @@
namespace FundLab.Api
open System
+open System.Globalization
+open System.IO
+open System.Text.Json
open Giraffe
open Microsoft.AspNetCore.Http
@@ -10,7 +13,103 @@ type EmptyPortfolioResponse =
message: string
}
+type FundApiResponse =
+ {
+ id: Guid
+ name: string
+ currency: string
+ initialCash: string
+ initialUnitNav: string
+ isSynthetic: bool
+ availableCash: string
+ status: string
+ }
+
+type ApiErrorResponse =
+ {
+ error: string
+ message: string
+ }
+
module App =
+ let private invariant = CultureInfo.InvariantCulture
+
+ let private cashText (value: decimal) = value.ToString("0.00", invariant)
+ let private unitNavText (value: decimal) = value.ToString("0.00000000", invariant)
+
+ let private fundResponse (fund: FundRecord) : FundApiResponse =
+ {
+ id = fund.Id
+ name = fund.Name
+ currency = fund.Currency
+ initialCash = cashText fund.InitialCash
+ initialUnitNav = unitNavText fund.InitialUnitNav
+ isSynthetic = fund.IsSynthetic
+ availableCash = cashText fund.AvailableCash
+ status = fund.Status
+ }
+
+ let private errorResponse status error message : HttpHandler =
+ setStatusCode status
+ >=> json ({
+ error = error
+ message = message
+ } : ApiErrorResponse)
+
+ let private tryStringProperty (root: JsonElement) (name: string) =
+ let mutable property = Unchecked.defaultof<JsonElement>
+
+ if root.TryGetProperty(name, &property) && property.ValueKind = JsonValueKind.String then
+ property.GetString() |> Option.ofObj
+ else
+ None
+
+ let private tryBoolProperty (root: JsonElement) (name: string) =
+ let mutable property = Unchecked.defaultof<JsonElement>
+
+ if root.TryGetProperty(name, &property) then
+ match property.ValueKind with
+ | JsonValueKind.True -> Some true
+ | JsonValueKind.False -> Some false
+ | _ -> None
+ else
+ None
+
+ let private tryDecimal (label: string) (text: string) =
+ if String.IsNullOrWhiteSpace text then
+ Error(sprintf "%s must be a decimal string" label)
+ else
+ match Decimal.TryParse(text, NumberStyles.AllowLeadingSign ||| NumberStyles.AllowDecimalPoint, invariant) with
+ | true, value -> Ok value
+ | false, _ -> Error(sprintf "%s must be a decimal string" label)
+
+ let private parseFundCommand (body: string) =
+ try
+ use document = JsonDocument.Parse(body)
+ let root = document.RootElement
+
+ if root.ValueKind <> JsonValueKind.Object then
+ Error "request body must be a JSON object"
+ else
+ match tryStringProperty root "name", tryStringProperty root "initialCash", tryStringProperty root "initialUnitNav", tryBoolProperty root "isSynthetic" with
+ | Some name, Some initialCashText, Some initialUnitNavText, Some isSynthetic ->
+ match tryDecimal "initialCash" initialCashText, tryDecimal "initialUnitNav" initialUnitNavText with
+ | Ok initialCash, Ok initialUnitNav ->
+ Ok {
+ Name = name
+ InitialCash = initialCash
+ InitialUnitNav = initialUnitNav
+ IsSynthetic = isSynthetic
+ }
+ | Error message, _
+ | _, Error message -> Error message
+ | _ ->
+ Error "name, initialCash, initialUnitNav and isSynthetic are required"
+ with
+ | :? JsonException -> Error "request body must be valid JSON"
+
+ let private invokeHandler handler next ctx = handler next ctx
+
let private unauthorized : HttpHandler =
setStatusCode 401
>=> setHttpHeader "WWW-Authenticate" "Bearer"
@@ -35,18 +134,57 @@ module App =
} : HealthResponse)
let private emptyPortfolio : HttpHandler =
- json {
- status = "empty"
- message = "尚未创建基金/尚未选择投资"
- }
+ json ({
+ status = "empty"
+ message = "尚未创建基金/尚未选择投资"
+ } : EmptyPortfolioResponse)
+
+ let private createFund (repository: FundRepository) : HttpHandler =
+ fun next ctx ->
+ task {
+ use reader = new StreamReader(ctx.Request.Body)
+ let! body = reader.ReadToEndAsync()
+ let idempotencyKey = ctx.Request.Headers["Idempotency-Key"].ToString()
+
+ match parseFundCommand body with
+ | Error message ->
+ return! invokeHandler (errorResponse 400 "INVALID_FUND_REQUEST" message) next ctx
+ | Ok command ->
+ try
+ match repository.CreateFund(idempotencyKey, command) with
+ | FundWriteResult.Created fund ->
+ return! invokeHandler (setStatusCode 201 >=> json (fundResponse fund)) next ctx
+ | FundWriteResult.Replayed fund ->
+ return! invokeHandler (json (fundResponse fund)) next ctx
+ | FundWriteResult.IdempotencyConflict ->
+ return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx
+ | FundWriteResult.Invalid message ->
+ return! invokeHandler (errorResponse 400 "INVALID_FUND_REQUEST" message) next ctx
+ with _ ->
+ return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "fund persistence failed") next ctx
+ }
+
+ let private getFund (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
+ | Some fund -> json (fundResponse fund) next ctx
+ | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "fund persistence failed" next ctx
- let createApplication () : HttpHandler =
+ let createApplication (repository: FundRepository) : HttpHandler =
choose [
GET >=> route "/health" >=> health
subRoute "/api" (
requireBearer
>=> choose [
GET >=> route "/portfolio/summary" >=> emptyPortfolio
+ POST >=> route "/funds" >=> createFund repository
+ GET >=> routef "/funds/%s" (getFund repository)
]
)
setStatusCode 404 >=> text "Not Found"