diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/FundLab.Api/App.fs | 148 | ||||
| -rw-r--r-- | src/FundLab.Api/FundLab.Api.fsproj | 2 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 282 | ||||
| -rw-r--r-- | src/FundLab.Api/Program.fs | 10 |
4 files changed, 436 insertions, 6 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" diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj index 81952df..8a3f590 100644 --- a/src/FundLab.Api/FundLab.Api.fsproj +++ b/src/FundLab.Api/FundLab.Api.fsproj @@ -7,6 +7,7 @@ </PropertyGroup> <ItemGroup> <PackageReference Include="Giraffe" Version="6.0.0" /> + <PackageReference Include="Npgsql" Version="8.0.8" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\FundLab.Domain\FundLab.Domain.fsproj" /> @@ -14,6 +15,7 @@ <ItemGroup> <Compile Include="Authentication.fs" /> <Compile Include="Health.fs" /> + <Compile Include="Persistence.fs" /> <Compile Include="App.fs" /> <Compile Include="Program.fs" /> </ItemGroup> diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs new file mode 100644 index 0000000..db0fb6f --- /dev/null +++ b/src/FundLab.Api/Persistence.fs @@ -0,0 +1,282 @@ +namespace FundLab.Api + +open System +open System.Data +open System.Data.Common +open System.Globalization +open System.Security.Cryptography +open System.Text +open FundLab.Domain +open Npgsql +open NpgsqlTypes + +[<CLIMutable>] +type FundCreateCommand = + { + Name: string + InitialCash: decimal + InitialUnitNav: decimal + IsSynthetic: bool + } + +type FundRecord = + { + Id: Guid + Name: string + Currency: string + InitialCash: decimal + InitialUnitNav: decimal + IsSynthetic: bool + AvailableCash: decimal + Status: string + } + +type FundWriteResult = + | Created of FundRecord + | Replayed of FundRecord + | IdempotencyConflict + | Invalid of string + +type FundRepository(connectionString: string) = + let cashMaximum = 999999999999999999.99m + let unitNavMaximum = 99999999999999999999.99999999m + + let schema = + """ + CREATE TABLE IF NOT EXISTS funds ( + id uuid PRIMARY KEY, + name text NOT NULL, + currency text NOT NULL, + initial_cash numeric(20, 2) NOT NULL, + initial_unit_nav numeric(28, 8) NOT NULL, + is_synthetic boolean NOT NULL, + available_cash numeric(20, 2) NOT NULL, + status text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS fund_idempotencies ( + idempotency_key text PRIMARY KEY, + request_hash text NOT NULL, + fund_id uuid NOT NULL REFERENCES funds(id), + created_at timestamptz NOT NULL DEFAULT now() + ); + """ + + let statusText status = + match status with + | FundStatus.Empty -> "empty" + | FundStatus.Active -> "active" + | FundStatus.ZeroUnits -> "zero_units" + + let recordFromLedger (fund: LedgerFund) = + { + Id = fund.Id + Name = fund.Name + Currency = fund.Currency + InitialCash = fund.InitialCash + InitialUnitNav = fund.InitialUnitNav + IsSynthetic = fund.IsSynthetic + AvailableCash = fund.AvailableCash + Status = statusText fund.Status + } + + let recordFromReader (reader: DbDataReader) = + { + Id = reader.GetGuid(0) + Name = reader.GetString(1) + Currency = reader.GetString(2) + InitialCash = reader.GetDecimal(3) + InitialUnitNav = reader.GetDecimal(4) + IsSynthetic = reader.GetBoolean(5) + AvailableCash = reader.GetDecimal(6) + Status = reader.GetString(7) + } + + let commandWithTransaction (connection: NpgsqlConnection) (transaction: NpgsqlTransaction option) sql = + let command = connection.CreateCommand() + command.CommandText <- sql + + match transaction with + | Some value -> command.Transaction <- value + | None -> () + + command + + let addParameter (command: NpgsqlCommand) name dbType (value: obj) = + let parameter = command.Parameters.Add(name, dbType) + parameter.Value <- value + parameter + + let findFund connection transaction fundId = + use command = + commandWithTransaction + connection + transaction + """ + SELECT id, name, currency, initial_cash, initial_unit_nav, + is_synthetic, available_cash, status + FROM funds + WHERE id = @fund_id + """ + + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + use reader = command.ExecuteReader() + if reader.Read() then Some(recordFromReader reader) else None + + let findIdempotency connection transaction key = + use command = + commandWithTransaction + connection + transaction + "SELECT request_hash, fund_id FROM fund_idempotencies WHERE idempotency_key = @idempotency_key" + + addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore + + use reader = command.ExecuteReader() + if reader.Read() then Some(reader.GetString(0), reader.GetGuid(1)) else None + + let insertFund connection transaction (fund: LedgerFund) = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO funds + (id, name, currency, initial_cash, initial_unit_nav, + is_synthetic, available_cash, status) + VALUES + (@id, @name, @currency, @initial_cash, @initial_unit_nav, + @is_synthetic, @available_cash, @status) + """ + + addParameter command "id" NpgsqlDbType.Uuid (box fund.Id) |> ignore + addParameter command "name" NpgsqlDbType.Text (box fund.Name) |> ignore + addParameter command "currency" NpgsqlDbType.Text (box fund.Currency) |> ignore + addParameter command "initial_cash" NpgsqlDbType.Numeric (box fund.InitialCash) |> ignore + addParameter command "initial_unit_nav" NpgsqlDbType.Numeric (box fund.InitialUnitNav) |> ignore + addParameter command "is_synthetic" NpgsqlDbType.Boolean (box fund.IsSynthetic) |> ignore + addParameter command "available_cash" NpgsqlDbType.Numeric (box fund.AvailableCash) |> ignore + addParameter command "status" NpgsqlDbType.Text (box (statusText fund.Status)) |> ignore + command.ExecuteNonQuery() |> ignore + + let insertIdempotency connection transaction key requestHash fundId = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO fund_idempotencies (idempotency_key, request_hash, fund_id) + VALUES (@idempotency_key, @request_hash, @fund_id) + """ + + addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore + addParameter command "request_hash" NpgsqlDbType.Text (box requestHash) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + command.ExecuteNonQuery() |> ignore + + let requestHash (command: FundCreateCommand) = + let invariant = CultureInfo.InvariantCulture + let encoded (value: string) = sprintf "%d:%s" value.Length value + let name = if isNull command.Name then "" else command.Name + let payload = + String.concat + "|" + [ + "fund-create" + encoded name + (encoded (command.InitialCash.ToString("G29", invariant))) + (encoded (command.InitialUnitNav.ToString("G29", invariant))) + (encoded (command.IsSynthetic.ToString())) + ] + + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))) + + let validateStorageRange (command: FundCreateCommand) = + if command.InitialCash > cashMaximum then + Error "initial cash exceeds database precision" + elif command.InitialUnitNav > unitNavMaximum then + Error "initial unit NAV exceeds database precision" + else + Ok() + + let ledgerErrorMessage error = + match error with + | InvalidIdentifier label -> sprintf "%s is invalid" label + | InvalidAmount label -> sprintf "%s is invalid" label + | InvalidPrecision label -> sprintf "%s has invalid precision" label + | InvalidState message -> message + | FundAlreadyExists fundId -> sprintf "fund %O already exists" fundId + | other -> sprintf "%A" other + + member _.EnsureSchema() = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- schema + command.ExecuteNonQuery() |> ignore + + member _.GetFund(fundId: Guid) = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + findFund connection None fundId + + member _.CreateFund(idempotencyKey: string, command: FundCreateCommand) = + if String.IsNullOrWhiteSpace idempotencyKey then + FundWriteResult.Invalid "idempotency key cannot be empty" + else + match validateStorageRange command with + | Error message -> FundWriteResult.Invalid message + | Ok() -> + let fingerprint = requestHash command + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted) + + try + use lockCommand = + commandWithTransaction + connection + (Some transaction) + "SELECT pg_advisory_xact_lock(hashtext(@lock_key))" + + addParameter lockCommand "lock_key" NpgsqlDbType.Text (box idempotencyKey) |> ignore + lockCommand.ExecuteNonQuery() |> ignore + + match findIdempotency connection (Some transaction) idempotencyKey with + | Some(existingHash, fundId) when existingHash = fingerprint -> + match findFund connection (Some transaction) fundId with + | Some fund -> + transaction.Commit() + FundWriteResult.Replayed fund + | None -> + transaction.Rollback() + FundWriteResult.Invalid "idempotency record references a missing fund" + | Some _ -> + transaction.Rollback() + FundWriteResult.IdempotencyConflict + | None -> + let fundId = Guid.NewGuid() + + match Ledger.initializeFund fundId command.Name command.InitialCash command.InitialUnitNav command.IsSynthetic Ledger.empty with + | Error error -> + transaction.Rollback() + FundWriteResult.Invalid(ledgerErrorMessage error) + | Ok state -> + match Ledger.getFund fundId state with + | Error error -> + transaction.Rollback() + FundWriteResult.Invalid(ledgerErrorMessage error) + | Ok fund -> + insertFund connection (Some transaction) fund + insertIdempotency connection (Some transaction) idempotencyKey fingerprint fundId + transaction.Commit() + FundWriteResult.Created(recordFromLedger fund) + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs index d5cc0b2..4c757d6 100644 --- a/src/FundLab.Api/Program.fs +++ b/src/FundLab.Api/Program.fs @@ -1,15 +1,23 @@ module FundLab.Api.Program +open System open Giraffe open Microsoft.AspNetCore.Builder open Microsoft.Extensions.DependencyInjection +let private requiredEnvironment name = + match Environment.GetEnvironmentVariable(name) with + | value when not (String.IsNullOrWhiteSpace value) -> value + | _ -> failwithf "%s must be configured" name + [<EntryPoint>] let main argv = let builder = WebApplication.CreateBuilder(argv) builder.Services.AddGiraffe() |> ignore + let repository = FundRepository(requiredEnvironment "FUND_LAB_DATABASE_URL") + repository.EnsureSchema() let app = builder.Build() - app.UseGiraffe(App.createApplication()) + app.UseGiraffe(App.createApplication(repository)) app.Run() 0 |
