namespace FundLab.Api open System open System.Globalization open System.IO open System.Text.Json open Giraffe open Microsoft.AspNetCore.Http type EmptyPortfolioResponse = { status: string 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 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 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" >=> text "Unauthorized" let private requireBearer (next: HttpFunc) (ctx: HttpContext) = let expectedToken = Environment.GetEnvironmentVariable("FUND_LAB_AUTH_TOKEN") |> Option.ofObj |> Option.defaultValue "" let authorizationHeader = ctx.Request.Headers.Authorization.ToString() match Authentication.authorize expectedToken authorizationHeader with | AuthDecision.Authorized -> next ctx | AuthDecision.Unauthorized -> unauthorized next ctx let private health : HttpHandler = json ({ service = "fund-lab-api" status = "ok" } : HealthResponse) let private emptyPortfolio : HttpHandler = 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 (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" ]