From 898a7d660fe39d056e1dbe7cec9a7c5a3b5152ab Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Thu, 3 Jan 2019 17:15:48 +0000 Subject: [PATCH 01/17] WIP --- equinox-web-csharp.sln | 33 ++++ .../.template.config/template.json | 80 +++++++++ equinox-web-csharp/Domain/Aggregate.fs | 63 +++++++ equinox-web-csharp/Domain/ClientId.fs | 43 +++++ equinox-web-csharp/Domain/Domain.fsproj | 24 +++ equinox-web-csharp/Domain/TodosService.fs | 131 +++++++++++++++ .../Web/Controllers/TodosController.cs | 63 +++++++ equinox-web-csharp/Web/CosmosContext.cs | 95 +++++++++++ equinox-web-csharp/Web/EquinoxContext.cs | 44 +++++ equinox-web-csharp/Web/EventStoreContext.cs | 81 +++++++++ equinox-web-csharp/Web/MemoryStoreContext.cs | 34 ++++ equinox-web-csharp/Web/Program.cs | 35 ++++ .../Web/Properties/launchSettings.json | 26 +++ equinox-web-csharp/Web/Startup.cs | 155 ++++++++++++++++++ equinox-web-csharp/Web/Web.csproj | 32 ++++ .../Web/appsettings.Development.json | 9 + equinox-web-csharp/Web/appsettings.json | 8 + 17 files changed, 956 insertions(+) create mode 100755 equinox-web-csharp.sln create mode 100755 equinox-web-csharp/.template.config/template.json create mode 100755 equinox-web-csharp/Domain/Aggregate.fs create mode 100755 equinox-web-csharp/Domain/ClientId.fs create mode 100755 equinox-web-csharp/Domain/Domain.fsproj create mode 100755 equinox-web-csharp/Domain/TodosService.fs create mode 100755 equinox-web-csharp/Web/Controllers/TodosController.cs create mode 100644 equinox-web-csharp/Web/CosmosContext.cs create mode 100644 equinox-web-csharp/Web/EquinoxContext.cs create mode 100644 equinox-web-csharp/Web/EventStoreContext.cs create mode 100644 equinox-web-csharp/Web/MemoryStoreContext.cs create mode 100755 equinox-web-csharp/Web/Program.cs create mode 100755 equinox-web-csharp/Web/Properties/launchSettings.json create mode 100755 equinox-web-csharp/Web/Startup.cs create mode 100755 equinox-web-csharp/Web/Web.csproj create mode 100755 equinox-web-csharp/Web/appsettings.Development.json create mode 100755 equinox-web-csharp/Web/appsettings.json diff --git a/equinox-web-csharp.sln b/equinox-web-csharp.sln new file mode 100755 index 000000000..30bd5c2a2 --- /dev/null +++ b/equinox-web-csharp.sln @@ -0,0 +1,33 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26124.0 +MinimumVisualStudioVersion = 15.0.26124.0 +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Web", "equinox-web-csharp\Web\Web.csproj", "{6C72C937-ECFC-4DD4-9BA0-7355B237F974}" +EndProject +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Domain", "equinox-web-csharp\Domain\Domain.fsproj", "{E87F1E85-B2CE-436D-9992-702C068DD338}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{518EE7E2-76AF-4DE9-A127-C2DFF709A468}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6C72C937-ECFC-4DD4-9BA0-7355B237F974}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C72C937-ECFC-4DD4-9BA0-7355B237F974}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C72C937-ECFC-4DD4-9BA0-7355B237F974}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C72C937-ECFC-4DD4-9BA0-7355B237F974}.Release|Any CPU.Build.0 = Release|Any CPU + {E87F1E85-B2CE-436D-9992-702C068DD338}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E87F1E85-B2CE-436D-9992-702C068DD338}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E87F1E85-B2CE-436D-9992-702C068DD338}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E87F1E85-B2CE-436D-9992-702C068DD338}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {2232D6B6-0CA3-4E51-BFD4-DB0A42EF34BF} + EndGlobalSection +EndGlobal diff --git a/equinox-web-csharp/.template.config/template.json b/equinox-web-csharp/.template.config/template.json new file mode 100755 index 000000000..717de6d61 --- /dev/null +++ b/equinox-web-csharp/.template.config/template.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "@jet @bartelink", + "classifications": [ + "F#", + "Web", + "Equinox", + "Event Sourcing" + ], + "tags": { + "language": "F#" + }, + "identity": "Equinox.Template", + "name": "Equinox Web App", + "shortName": "equinoxweb", + "sourceName": "TodoBackend", + "preferNameDirectory": true, + + "symbols": { + "aggregate": { + "type": "parameter", + "datatype": "bool", + "isRequired": false, + "defaultValue": "true", + "description": "Generate an example Aggregate." + }, + "todos": { + "type": "parameter", + "datatype": "bool", + "isRequired": false, + "defaultValue": "false", + "description": "Generate an example TodosController in your project, together with associated Domain logic." + }, + "memoryStore": { + "type": "parameter", + "dataType": "bool", + "defaultValue": "false", + "description": "'Store' Events in an In-Memory volatile store (for test purposes only.)" + }, + "eventStore": { + "type": "parameter", + "dataType": "bool", + "defaultValue": "false", + "description": "Store Events in an EventStore Cluster (see https://eventstore.org)" + }, + "cosmos": { + "type": "parameter", + "dataType": "bool", + "defaultValue": "false", + "description": "Store Events in an Azure CosmosDb Account" + }, + "cosmosSimulator": { + "type": "parameter", + "dataType": "bool", + "defaultValue": "false", + "description": "Include code/comments regarding Azure CosmosDb simulator" + } + }, + "sources": [ + { + "modifiers": [ + { + "condition": "(!todos)", + "exclude": [ + "*/Controllers/**/*", + "**/TodosService.fs", + "**/ClientId.fs", + "README.md" + ] + }, + { + "condition": "(!aggregate)", + "exclude": [ + "**/Aggregate.fs" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/equinox-web-csharp/Domain/Aggregate.fs b/equinox-web-csharp/Domain/Aggregate.fs new file mode 100755 index 000000000..c8763c50d --- /dev/null +++ b/equinox-web-csharp/Domain/Aggregate.fs @@ -0,0 +1,63 @@ +module TodoBackendTemplate.Aggregate + +// NB - these types and names reflect the actual storage formats and hence need to be versioned with care +module Events = + type Compacted = { happened: bool } + + type Event = + | Happened + | Compacted of Compacted + interface TypeShape.UnionContract.IUnionContract + +module Folds = + + type State = { happened: bool } + let initial = { happened = false } + let evolve s = function + | Events.Happened -> { happened = true } + | Events.Compacted e -> { happened = e.happened} + let fold (state : State) : Events.Event seq -> State = Seq.fold evolve state + let isOrigin = function Events.Compacted _ -> true | _ -> false + let compact state = Events.Compacted { happened = state.happened } + +module Commands = + + type Command = + | MakeItSo + + let interpret c (state : Folds.State) = + match c with + | MakeItSo -> if state.happened then [] else [Events.Happened] + +type Handler(log, stream, ?maxAttempts) = + + let inner = Equinox.Handler(Folds.fold, log, stream, maxAttempts = defaultArg maxAttempts 2) + + /// Execute `command`, syncing any events decided upon + member __.Execute command : Async = + inner.Decide <| fun ctx -> + ctx.Execute (Commands.interpret command) + /// Establish the present state of the Stream, project from that as specified by `projection` + member __.Query(projection : Folds.State -> 't) : Async<'t> = + inner.Query projection + +type View = { sorted : bool } + +type Service(handlerLog, resolve) = + + let (|CategoryId|) (id: string) = Equinox.CatId("Aggregate", id) + + /// Maps a ClientId to Handler for the relevant stream + let (|Stream|) (CategoryId catId) = Handler(handlerLog, resolve catId) + + let render (s: Folds.State) : View = + { sorted = s.happened } + + /// Read the present state + // TOCONSIDER: you should probably be separating this out per CQRS and reading from a denormalized/cached set of projections + member __.Read(Stream stream) : Async = + stream.Query (fun s -> render s) + + /// Execute the specified command + member __.Execute(Stream stream, command) : Async = + stream.Execute command \ No newline at end of file diff --git a/equinox-web-csharp/Domain/ClientId.fs b/equinox-web-csharp/Domain/ClientId.fs new file mode 100755 index 000000000..f9da4d00a --- /dev/null +++ b/equinox-web-csharp/Domain/ClientId.fs @@ -0,0 +1,43 @@ +namespace TodoBackendTemplate + +open System +open System.Runtime.Serialization + +/// Endows any type that inherits this class with standard .NET comparison semantics using a supplied token identifier +[] +type Comparable<'TComp, 'Token when 'TComp :> Comparable<'TComp, 'Token> and 'Token : comparison>(token : 'Token) = + member private __.Token = token // I can haz protected? + override x.Equals y = match y with :? Comparable<'TComp, 'Token> as y -> x.Token = y.Token | _ -> false + override __.GetHashCode() = hash token + interface IComparable with + member x.CompareTo y = + match y with + | :? Comparable<'TComp, 'Token> as y -> compare x.Token y.Token + | _ -> invalidArg "y" "invalid comparand" + +/// ClientId strongly typed id +[] +// To support model binding using aspnetcore 2 FromHeader +[)>] +// (Internally a string for most efficient copying semantics) +type ClientId private (id : string) = + inherit Comparable(id) + [] // Prevent swashbuckle inferring there's a "value" field + member __.Value = id + override __.ToString () = id + // NB tests lean on having a ctor of this shape + new (guid: Guid) = ClientId (guid.ToString("N")) + // NB for validation [and XSS] purposes we must prove it translatable to a Guid + static member Parse(input: string) = ClientId (Guid.Parse input) +and private ClientIdStringConverter() = + inherit System.ComponentModel.TypeConverter() + override __.CanConvertFrom(context, sourceType) = + sourceType = typedefof || base.CanConvertFrom(context,sourceType) + override __.ConvertFrom(context, culture, value) = + match value with + | :? string as s -> s |> ClientId.Parse |> box + | _ -> base.ConvertFrom(context, culture, value) + override __.ConvertTo(context, culture, value, destinationType) = + match value with + | :? ClientId as value when destinationType = typedefof -> value.Value :> _ + | _ -> base.ConvertTo(context, culture, value, destinationType) \ No newline at end of file diff --git a/equinox-web-csharp/Domain/Domain.fsproj b/equinox-web-csharp/Domain/Domain.fsproj new file mode 100755 index 000000000..cec1a25b7 --- /dev/null +++ b/equinox-web-csharp/Domain/Domain.fsproj @@ -0,0 +1,24 @@ + + + + netstandard2.0 + 5 + false + true + + + + TRACE;DEBUG;todos + + + + + + + + + + + + + \ No newline at end of file diff --git a/equinox-web-csharp/Domain/TodosService.fs b/equinox-web-csharp/Domain/TodosService.fs new file mode 100755 index 000000000..ed58aea58 --- /dev/null +++ b/equinox-web-csharp/Domain/TodosService.fs @@ -0,0 +1,131 @@ +module TodoBackendTemplate.Todo + +// NB - these types and names reflect the actual storage formats and hence need to be versioned with care +module Events = + + /// Information we retain per Todo List entry + type ItemData = { id: int; order: int; title: string; completed: bool } + /// Events we keep in Todo-* streams + type Event = + | Added of ItemData + | Updated of ItemData + | Deleted of int + /// Cleared also `isOrigin` (see below) - if we see one of these, we know we don't need to look back any further + | Cleared + /// For EventStore, AccessStrategy.RollingSnapshots embeds these events every `batchSize` events + | Compacted of ItemData[] + interface TypeShape.UnionContract.IUnionContract + +/// Types and mapping logic used maintain relevant State based on Events observed on the Todo List Stream +module Folds = + + /// Present state of the Todo List as inferred from the Events we've seen to date + type State = { items : Events.ItemData list; nextId : int } + /// State implied by the absence of any events on this stream + let initial = { items = []; nextId = 0 } + /// Compute State change implied by a given Event + let evolve s = function + | Events.Added item -> { s with items = item :: s.items; nextId = s.nextId + 1 } + | Events.Updated value -> { s with items = s.items |> List.map (function { id = id } when id = value.id -> value | item -> item) } + | Events.Deleted id -> { s with items = s.items |> List.filter (fun x -> x.id <> id) } + | Events.Cleared -> { s with items = [] } + | Events.Compacted items -> { s with items = List.ofArray items } + /// Folds a set of events from the store into a given `state` + let fold (state : State) : Events.Event seq -> State = Seq.fold evolve state + /// Determines whether a given event represents a checkpoint that implies we don't need to see any preceding events + let isOrigin = function Events.Cleared | Events.Compacted _ -> true | _ -> false + /// Prepares an Event that encodes all relevant aspects of a State such that `evolve` can rehydrate a complete State from it + let compact state = Events.Compacted (Array.ofList state.items) + +/// Properties that can be edited on a Todo List item +type Props = { order: int; title: string; completed: bool } + +/// Defines the decion process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream +module Commands = + + /// Defines the operations a caller can perform on a Todo List + type Command = + /// Create a single item + | Add of Props + /// Update a single item + | Update of id: int * Props + /// Delete a single item from the list + | Delete of id: int + /// Complete clear the todo list + | Clear + + let interpret c (state : Folds.State) = + let mkItem id (value: Props): Events.ItemData = { id = id; order=value.order; title=value.title; completed=value.completed } + match c with + | Add value -> [Events.Added (mkItem state.nextId value)] + | Update (itemId,value) -> + let proposed = mkItem itemId value + match state.items |> List.tryFind (function { id = id } -> id = itemId) with + | Some current when current <> proposed -> [Events.Updated proposed] + | _ -> [] + | Delete id -> if state.items |> List.exists (fun x -> x.id = id) then [Events.Deleted id] else [] + | Clear -> if state.items |> List.isEmpty then [] else [Events.Cleared] + +/// Defines low level stream operations relevant to the Todo Stream in terms of Command and Events +type Handler(log, stream, ?maxAttempts) = + + let inner = Equinox.Handler(Folds.fold, log, stream, maxAttempts = defaultArg maxAttempts 2) + + /// Execute `command`; does not emit the post state + member __.Execute command : Async = + inner.Decide <| fun ctx -> + ctx.Execute (Commands.interpret command) + /// Handle `command`, return the items after the command's intent has been applied to the stream + member __.Handle command : Async = + inner.Decide <| fun ctx -> + ctx.Execute (Commands.interpret command) + ctx.State.items + /// Establish the present state of the Stream, project from that as specified by `projection` + member __.Query(projection : Folds.State -> 't) : Async<'t> = + inner.Query projection + +/// A single Item in the Todo List +type View = { id: int; order: int; title: string; completed: bool } + +/// Defines operations that a Controller can perform on a Todo List +type Service(handlerLog, resolve) = + + /// Maps a ClientId to the CatId that specifies the Stream in whehch the data for that client will be held + let (|CategoryId|) (clientId: ClientId) = Equinox.CatId("Todos", if obj.ReferenceEquals(clientId,null) then "1" else clientId.Value) + + /// Maps a ClientId to Handler for the relevant stream + let (|Stream|) (CategoryId catId) = Handler(handlerLog, resolve catId) + + let render (item: Events.ItemData) : View = + { id = item.id + order = item.order + title = item.title + completed = item.completed } + + (* READ *) + + /// List all open items + member __.List(Stream stream) : Async = + stream.Query (fun x -> seq { for x in x.items -> render x }) + + /// Load details for a single specific item + member __.TryGet(Stream stream, id) : Async = + stream.Query (fun x -> x.items |> List.tryFind (fun x -> x.id = id) |> Option.map render) + + (* WRITE *) + + /// Execute the specified (blind write) command + member __.Execute(Stream stream, command) : Async = + stream.Execute command + + (* WRITE-READ *) + + /// Create a new ToDo List item; response contains the generated `id` + member __.Create(Stream stream, template: Props) : Async = async { + let! state' = stream.Handle(Commands.Add template) + return List.head state' |> render } + + /// Update the specified item as referenced by the `item.id` + member __.Patch(Stream stream, id: int, value: Props) : Async = async { + let! state' = stream.Handle(Commands.Update (id, value)) + return state' |> List.find (fun x -> x.id = id) |> render} \ No newline at end of file diff --git a/equinox-web-csharp/Web/Controllers/TodosController.cs b/equinox-web-csharp/Web/Controllers/TodosController.cs new file mode 100755 index 000000000..0f084701c --- /dev/null +++ b/equinox-web-csharp/Web/Controllers/TodosController.cs @@ -0,0 +1,63 @@ +namespace TodoBackend.Controllers + +open Microsoft.AspNetCore.Mvc +open TodoBackend + +type FromClientIdHeaderAttribute() = inherit FromHeaderAttribute(Name="COMPLETELY_INSECURE_CLIENT_ID") + +type TodoView = + { id: int + url: string + order: int; title: string; completed: bool } + +type GetByIdArgsTemplate = { id: int } + +// Fulfills contract dictated by https://www.todobackend.com +// To run: +// & dotnet run -f netcoreapp2.1 -p Web +// https://www.todobackend.com/client/index.html?https://localhost:5001/todos +// # NB Jet does now own, control or audit https://todobackend.com; it is a third party site; please satisfy yourself that this is a safe thing use in your environment before using it._ +// See also similar backends used as references when implementing: +// https://github.com/ChristianAlexander/dotnetcore-todo-webapi/blob/master/src/TodoWebApi/Controllers/TodosController.cs +// https://github.com/joeaudette/playground/blob/master/spa-stack/src/FSharp.WebLib/Controllers.fs +[] +type TodosController(service: Todo.Service) = + inherit ControllerBase() + + let toProps (value : TodoView) : Todo.Props = { order = value.order; title = value.title; completed = value.completed } + + member private __.WithUri(x : Todo.View) : TodoView = + let url = __.Url.RouteUrl("GetTodo", { id=x.id }, __.Request.Scheme) // Supplying scheme is secret sauce for making it absolute as required by client + { id = x.id; url = url; order = x.order; title = x.title; completed = x.completed } + + [] + member __.Get([]clientId : ClientId) = async { + let! xs = service.List(clientId) + return seq { for x in xs -> __.WithUri(x) } + } + + [] + member __.Get([]clientId : ClientId, id) : Async = async { + let! x = service.TryGet(clientId, id) + return match x with None -> __.NotFound() :> _ | Some x -> ObjectResult(__.WithUri x) :> _ + } + + [] + member __.Post([]clientId : ClientId, []value : TodoView) : Async = async { + let! created = service.Create(clientId, toProps value) + return __.WithUri created + } + + [] + member __.Patch([]clientId : ClientId, id, []value : TodoView) : Async = async { + let! updated = service.Patch(clientId, id, toProps value) + return __.WithUri updated + } + + [] + member __.Delete([]clientId : ClientId, id): Async = + service.Execute(clientId, Todo.Commands.Delete id) + + [] + member __.DeleteAll([]clientId : ClientId): Async = + service.Execute(clientId, Todo.Commands.Clear) \ No newline at end of file diff --git a/equinox-web-csharp/Web/CosmosContext.cs b/equinox-web-csharp/Web/CosmosContext.cs new file mode 100644 index 000000000..403b7c8fb --- /dev/null +++ b/equinox-web-csharp/Web/CosmosContext.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using Equinox; +using Equinox.Cosmos; +using Equinox.Store; +using Equinox.UnionCodec; +using Microsoft.FSharp.Control; +using Microsoft.FSharp.Core; +using Serilog; + +namespace TodoBackendTemplate +{ + public class CosmosConfig + { + public CosmosConfig(ConnectionMode mode, string connectionStringWithUriAndKey, string database, + string collection, int cacheMb) + { + Mode = mode; + ConnectionStringWithUriAndKey = connectionStringWithUriAndKey; + Database = database; + Collection = collection; + CacheMb = cacheMb; + } + + public ConnectionMode Mode { get; } + public string ConnectionStringWithUriAndKey { get; } + public string Database { get; } + public string Collection { get; } + public int CacheMb { get; } + } + + public class CosmosContext : EquinoxContext + { + readonly Lazy _store; + readonly Caching.Cache _cache; + + public CosmosContext(CosmosConfig config) + { + _cache = new Caching.Cache("Cosmos", config.CacheMb); + var retriesOn429Throttling = + 1; // Number of retries before failing processing when provisioned RU/s limit in CosmosDb is breached + var timeout = TimeSpan.FromSeconds(5); // Timeout applied per request to CosmosDb, including retry attempts + var discovery = Discovery.FromConnectionString(config.ConnectionStringWithUriAndKey); + _store = new Lazy(() => + { + var gateway = Connect("App", config.Mode, discovery, timeout, retriesOn429Throttling, + (int) timeout.TotalSeconds); + var collectionMapping = new EqxCollections(config.Database, config.Collection); + + return new EqxStore(gateway, collectionMapping, resolverLog: null); + }); + } + + private static EqxGateway Connect(string appName, ConnectionMode mode, Discovery discovery, TimeSpan operationTimeout, + int maxRetryForThrottling, int maxRetryWaitSeconds) + { + var log = Log.ForContext(); + var c = new EqxConnector(log: log, mode: mode, requestTimeout: + operationTimeout, maxRetryAttemptsOnThrottledRequests: maxRetryForThrottling, + maxRetryWaitTimeInSeconds: + maxRetryWaitSeconds, tags: null, maxConnectionLimit: null, defaultConsistencyLevel: null, + readRetryPolicy: null, writeRetryPolicy: null); + var conn = FSharpAsync.RunSynchronously(c.Connect(appName, discovery), null, null); + return new EqxGateway(conn, new EqxBatchingPolicy(defaultMaxItems: 500, getDefaultMaxItems: null, + maxRequests: null, maxEventsPerSlice: null + )); + } + + internal override void Connect() + { + var _ = _store.Value; + } + + public override IStream Resolve( + IUnionEncoder codec, + Func, TState> fold, + TState initial, + Target target, + Func isOrigin = null, + Func compact = null) + { + var accessStrategy = + isOrigin == null && compact == null + ? null + : AccessStrategy.NewSnapshot(FuncConvert.FromFunc(isOrigin), + FuncConvert.FromFunc(compact)); + + var cacheStrategy = _cache == null + ? null + : CachingStrategy.NewSlidingWindow(_cache, TimeSpan.FromMinutes(20)); + var resolver = new EqxResolver(_store.Value, codec, FuncConvert.FromFunc(fold), initial, accessStrategy, cacheStrategy); + return resolver.Resolve.Invoke(target); + } + } +} \ No newline at end of file diff --git a/equinox-web-csharp/Web/EquinoxContext.cs b/equinox-web-csharp/Web/EquinoxContext.cs new file mode 100644 index 000000000..8c16c74cd --- /dev/null +++ b/equinox-web-csharp/Web/EquinoxContext.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using Equinox; +using Microsoft.FSharp.Core; +using Newtonsoft.Json; +using TypeShape; + +namespace TodoBackendTemplate +{ + public abstract class EquinoxContext + { + public abstract Equinox.Store.IStream Resolve( + Equinox.UnionCodec.IUnionEncoder codec, + Func, TState> fold, + TState initial, + Target target, + Func isOrigin = null, + Func compact = null); + + internal abstract void Connect(); + } + + public static class EquinoxCodec + { + static readonly JsonSerializerSettings _defaultSerializationSettings = new Newtonsoft.Json.JsonSerializerSettings(); + + public static Equinox.UnionCodec.IUnionEncoder Create( + Func> encode, + Func tryDecode, + JsonSerializerSettings settings = null) where TEvent: class + { + return Equinox.UnionCodec.JsonUtf8.Create( + FuncConvert.FromFunc(encode), + FuncConvert.FromFunc((Func, FSharpOption>) TryDecodeImpl)); + FSharpOption TryDecodeImpl(Tuple encoded) => OptionModule.OfObj(tryDecode(encoded.Item1, encoded.Item2)); + } + + public static Equinox.UnionCodec.IUnionEncoder Create( + JsonSerializerSettings settings = null) where TEvent: UnionContract.IUnionContract + { + return Equinox.UnionCodec.JsonUtf8.Create(settings ?? _defaultSerializationSettings, null, null ); + } + } +} \ No newline at end of file diff --git a/equinox-web-csharp/Web/EventStoreContext.cs b/equinox-web-csharp/Web/EventStoreContext.cs new file mode 100644 index 000000000..91d5dc5c9 --- /dev/null +++ b/equinox-web-csharp/Web/EventStoreContext.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using Equinox; +using Equinox.EventStore; +using Equinox.Store; +using Equinox.UnionCodec; +using Microsoft.FSharp.Control; +using Microsoft.FSharp.Core; + +namespace TodoBackendTemplate +{ + public class EventStoreConfig + { + public EventStoreConfig(string host, string username, string password, int cacheMb) + { + Host = host; + Username = username; + Password = password; + CacheMb = cacheMb; + } + + public string Host { get; } + public string Username { get; } + public string Password { get; } + public int CacheMb { get; } + } + + public class EventStoreContext : EquinoxContext + { + readonly Lazy _gateway; + readonly Caching.Cache _cache; + + public EventStoreContext(EventStoreConfig config) + { + + _cache = new Caching.Cache("Es", config.CacheMb); + _gateway = new Lazy(() => Connect(config)); + } + + private static GesGateway Connect(EventStoreConfig config) + { + var log = Logger.NewSerilogNormal(Serilog.Log.ForContext()); + var c = new GesConnector(config.Username, config.Password, reqTimeout: TimeSpan.FromSeconds(5), + reqRetries: 1, + log: log, heartbeatTimeout: null, concurrentOperationsLimit: null, readRetryPolicy: null, + writeRetryPolicy: null, tags: null); + + var conn = FSharpAsync.RunSynchronously( + c.Establish("Twin", Discovery.NewGossipDns(config.Host), + ConnectionStrategy.ClusterTwinPreferSlaveReads), + null, null); + return new GesGateway(conn, + new GesBatchingPolicy(maxBatchSize: 500)); + } + + internal override void Connect() + { + var _ = _gateway.Value; + } + + public override IStream Resolve( + IUnionEncoder codec, + Func, TState> fold, + TState initial, + Target target, + Func isOrigin = null, + Func compact = null) + { + var accessStrategy = + isOrigin == null && compact == null + ? null + : AccessStrategy.NewRollingSnapshots(FuncConvert.FromFunc(isOrigin), FuncConvert.FromFunc(compact)); + var cacheStrategy = _cache == null + ? null + : CachingStrategy.NewSlidingWindow(_cache, TimeSpan.FromMinutes(20)); + var resolver = new GesResolver(_gateway.Value, codec, FuncConvert.FromFunc(fold), + initial, accessStrategy, cacheStrategy); + return resolver.Resolve.Invoke(target); + } + } +} \ No newline at end of file diff --git a/equinox-web-csharp/Web/MemoryStoreContext.cs b/equinox-web-csharp/Web/MemoryStoreContext.cs new file mode 100644 index 000000000..651557890 --- /dev/null +++ b/equinox-web-csharp/Web/MemoryStoreContext.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using Equinox.MemoryStore; +using Equinox.UnionCodec; +using Microsoft.FSharp.Core; + +namespace TodoBackendTemplate +{ + public class MemoryStoreContext : EquinoxContext + { + readonly VolatileStore _store; + + public MemoryStoreContext(VolatileStore store) + { + _store = store; + } + + public override Equinox.Store.IStream Resolve( + IUnionEncoder codec, + Func, TState> fold, + TState initial, + Equinox.Target target, + Func isOrigin = null, + Func compact = null) + { + var resolver = new MemResolver(_store, FuncConvert.FromFunc(fold), initial); + return resolver.Resolve.Invoke(target); + } + + internal override void Connect() + { + } + } +} \ No newline at end of file diff --git a/equinox-web-csharp/Web/Program.cs b/equinox-web-csharp/Web/Program.cs new file mode 100755 index 000000000..bfbf970b7 --- /dev/null +++ b/equinox-web-csharp/Web/Program.cs @@ -0,0 +1,35 @@ +using System; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Serilog; +using Serilog.Events; + +namespace TodoBackendTemplate.Web +{ + static class Program + { + public static int Main(string[] argv) + { + try + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); + WebHost + .CreateDefaultBuilder(argv) + .UseStartup() + .Build() + .Run(); + return 0; + } + catch (Exception e) + { + Console.Error.WriteLine(e.Message); + return 1; + } + } + } +} \ No newline at end of file diff --git a/equinox-web-csharp/Web/Properties/launchSettings.json b/equinox-web-csharp/Web/Properties/launchSettings.json new file mode 100755 index 000000000..163423618 --- /dev/null +++ b/equinox-web-csharp/Web/Properties/launchSettings.json @@ -0,0 +1,26 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:50328", + "sslPort": 44302 + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Web": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + } + } +} \ No newline at end of file diff --git a/equinox-web-csharp/Web/Startup.cs b/equinox-web-csharp/Web/Startup.cs new file mode 100755 index 000000000..c7cf61604 --- /dev/null +++ b/equinox-web-csharp/Web/Startup.cs @@ -0,0 +1,155 @@ +#define cosmos +#define eventStore +#define memoryStore +#define todos +#define aggregate +using System; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Serilog; + +namespace TodoBackendTemplate.Web +{ + /// Defines the Hosting configuration, including registration of the store and backend services + class Startup + { + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + if (env.IsDevelopment()) + app.UseDeveloperExceptionPage(); + else + app.UseHsts(); + + app.UseHttpsRedirection() +#if todos + // NB Jet does now own, control or audit https://todobackend.com; it is a third party site; please satisfy yourself that this is a safe thing use in your environment before using it._ + .UseCors(x => x.WithOrigins("https://www.todobackend.com").AllowAnyHeader().AllowAnyMethod()) +#endif + .UseMvc(); + } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + + { + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); + var equinoxContext = ConfigureStore(); + ConfigureServices(services, equinoxContext); + } + + private static void ConfigureServices(IServiceCollection services, EquinoxContext context) + { + services.AddSingleton(_ => + { + context.Connect(); + return context; + }); + services.AddSingleton(sp => new ServiceBuilder(context, Serilog.Log.ForContext())); +#if todos + services.AddSingleton(sp => sp.GetRequiredService().CreateTodosService()); +#endif +#if aggregate + services.AddSingleton(sp => sp.GetRequiredService().CreateAggregateService()); +#else + //services.Register(fun sp -> sp.Resolve().CreateThingService()) +#endif + + } + + private static EquinoxContext ConfigureStore() + { +#if cosmos || eventStore + // This is the allocation limit passed internally to a System.Caching.MemoryCache instance + // The primary objects held in the cache are the Folded State of Event-sourced aggregates + // see https://docs.microsoft.com/en-us/dotnet/framework/performance/caching-in-net-framework-applications for more information + var cacheMb = 50; + +#endif +#if eventStore + // EVENTSTORE: see https://eventstore.org/ + // Requires a Commercial HA Cluster, which can be simulated by 1) installing the OSS Edition from Choocolatey 2) running it in cluster mode + + // # requires admin privilege + // cinst eventstore-oss -y # where cinst is an invocation of the Chocolatey Package Installer on Windows + // # run as a single-node cluster to allow connection logic to use cluster mode as for a commercial cluster + // & $env:ProgramData\chocolatey\bin\EventStore.ClusterNode.exe --gossip-on-single-node --discover-via-dns 0 --ext-http-port=30778 + + var esConfig = new EventStoreConfig("localhost", "admin", "changeit", cacheMb); + return new EventStoreContext(esConfig); +#endif +#if cosmos + // AZURE COSMOSDB: Events are stored in an Azure CosmosDb Account (using the SQL API) + // Provisioning Steps: + // 1) Set the 3x environment variables EQUINOX_COSMOS_CONNECTION, EQUINOX_COSMOS_DATABASE, EQUINOX_COSMOS_COLLECTION + // 2) Provision a collection using the following command sequence: + // dotnet tool install -g Equinox.Cli + // Equinox.Cli init -ru 1000 cosmos -s $env:EQUINOX_COSMOS_CONNECTION -d $env:EQUINOX_COSMOS_DATABASE -c $env:EQUINOX_COSMOS_COLLECTION + const string connVar = "EQUINOX_COSMOS_CONNECTION"; + var conn = Environment.GetEnvironmentVariable(connVar); + const string dbVar = "EQUINOX_COSMOS_DATABASE"; + var db = Environment.GetEnvironmentVariable(dbVar); + const string collVar = "EQUINOX_COSMOS_COLLECTION"; + var coll = Environment.GetEnvironmentVariable(collVar); + if (conn == null || db == null || coll == null) + throw new Exception( + $"Event Storage subsystem requires the following Environment Variables to be specified: {connVar} {dbVar}, {collVar}"); + var connMode = Equinox.Cosmos.ConnectionMode.DirectTcp; + var config = new CosmosConfig(connMode, conn, db, coll, cacheMb); + return new CosmosContext(config); +#endif +#if (memoryStore || (!cosmos && !eventStore)) + return new MemoryStoreContext(new Equinox.MemoryStore.VolatileStore()); +#endif + } + } + + /// Binds a storage independent Service's Handler's `resolve` function to a given Stream Policy using the StreamResolver + internal class ServiceBuilder + { + readonly EquinoxContext _context; + readonly ILogger _handlerLog; + + public ServiceBuilder(EquinoxContext context, ILogger handlerLog) + { + _context = context; + _handlerLog = handlerLog; + } + +#if todos + public Todo.Service CreateTodosService() => + Todo.Service( + _handlerLog, + _context.Resolve( + EquinoxCodec.Create(), + Todo.Folds.fold, + Todo.Folds.initial, + Todo.Folds.isOrigin, + Todo.Folds.compact)); +#endif +#if aggregate + public Todo.Service CreateAggregateService() => + Aggregate.Service( + _handlerLog, + _context.Resolve( + EquinoxCodec.Create(), + Aggregate.Folds.fold, + Aggregate.Folds.initial, + Aggregate.Folds.isOrigin, + Aggregate.Folds.compact)); +#endif +#if (!aggregate && !todos) + public Thing.Service CreateAggregateService() => + Aggregate.Service( + _handlerLog, + _context.Resolve( + EquinoxCodec.Create(), + Thing.Folds.fold, + Thing.Folds.initial, + Thing.Folds.isOrigin, + Thing.Folds.compact)); +#endif + } +} diff --git a/equinox-web-csharp/Web/Web.csproj b/equinox-web-csharp/Web/Web.csproj new file mode 100755 index 000000000..ecec6ad7d --- /dev/null +++ b/equinox-web-csharp/Web/Web.csproj @@ -0,0 +1,32 @@ + + + + netcoreapp2.1 + + + + TRACE;DEBUG;NETCOREAPP;NETCOREAPP2_1;eventStore;cosmos;todos;aggregate + + + + + + + + + + + + + + + + + + + + ..\..\..\..\.nuget\packages\serilog\2.7.1\lib\netstandard1.3\Serilog.dll + + + + \ No newline at end of file diff --git a/equinox-web-csharp/Web/appsettings.Development.json b/equinox-web-csharp/Web/appsettings.Development.json new file mode 100755 index 000000000..a2880cbf1 --- /dev/null +++ b/equinox-web-csharp/Web/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/equinox-web-csharp/Web/appsettings.json b/equinox-web-csharp/Web/appsettings.json new file mode 100755 index 000000000..7376aada1 --- /dev/null +++ b/equinox-web-csharp/Web/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "AllowedHosts": "*" +} From 2440e101c6d64294c99799478bc1ca667a4b0501 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 4 Jan 2019 03:38:54 +0000 Subject: [PATCH 02/17] Port Basic Aggregate --- equinox-web-csharp.sln | 2 +- equinox-web-csharp/Domain/Aggregate.cs | 160 +++++++++++ equinox-web-csharp/Domain/Aggregate.fs | 63 ----- equinox-web-csharp/Domain/ClientId.cs | 36 +++ equinox-web-csharp/Domain/ClientId.fs | 43 --- .../Domain/{Domain.fsproj => Domain.csproj} | 45 ++- equinox-web-csharp/Domain/Infrastructure.cs | 63 +++++ .../{TodosService.fs => TodosService.cs} | 260 +++++++++--------- equinox-web-csharp/Web/CosmosContext.cs | 5 +- equinox-web-csharp/Web/EquinoxContext.cs | 3 +- equinox-web-csharp/Web/EventStoreContext.cs | 5 +- equinox-web-csharp/Web/MemoryStoreContext.cs | 7 +- equinox-web-csharp/Web/Startup.cs | 14 +- equinox-web-csharp/Web/Web.csproj | 9 +- 14 files changed, 429 insertions(+), 286 deletions(-) create mode 100755 equinox-web-csharp/Domain/Aggregate.cs delete mode 100755 equinox-web-csharp/Domain/Aggregate.fs create mode 100755 equinox-web-csharp/Domain/ClientId.cs delete mode 100755 equinox-web-csharp/Domain/ClientId.fs rename equinox-web-csharp/Domain/{Domain.fsproj => Domain.csproj} (58%) create mode 100644 equinox-web-csharp/Domain/Infrastructure.cs rename equinox-web-csharp/Domain/{TodosService.fs => TodosService.cs} (97%) diff --git a/equinox-web-csharp.sln b/equinox-web-csharp.sln index 30bd5c2a2..6edf300d1 100755 --- a/equinox-web-csharp.sln +++ b/equinox-web-csharp.sln @@ -5,7 +5,7 @@ VisualStudioVersion = 15.0.26124.0 MinimumVisualStudioVersion = 15.0.26124.0 Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Web", "equinox-web-csharp\Web\Web.csproj", "{6C72C937-ECFC-4DD4-9BA0-7355B237F974}" EndProject -Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Domain", "equinox-web-csharp\Domain\Domain.fsproj", "{E87F1E85-B2CE-436D-9992-702C068DD338}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Domain", "equinox-web-csharp\Domain\Domain.csproj", "{E87F1E85-B2CE-436D-9992-702C068DD338}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{518EE7E2-76AF-4DE9-A127-C2DFF709A468}" EndProject diff --git a/equinox-web-csharp/Domain/Aggregate.cs b/equinox-web-csharp/Domain/Aggregate.cs new file mode 100755 index 000000000..6f2fcda66 --- /dev/null +++ b/equinox-web-csharp/Domain/Aggregate.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Data.SqlTypes; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; +using Equinox; +using Equinox.Store; +using Microsoft.FSharp.Collections; +using Microsoft.FSharp.Core; +using Newtonsoft.Json; +using Serilog; + +namespace TodoBackendTemplate +{ + public class Aggregate + { + public interface IEvent + { + } + + /// NB - these types and names reflect the actual storage formats and hence need to be versioned with care + public static class Events + { + public class Happened : IEvent + { + } + + public class Compacted : IEvent + { + public bool Happened { get; set; } + } + + static JsonNetUtf8Codec _codec = new JsonNetUtf8Codec(new JsonSerializerSettings()); + + public static IEvent TryDecode(string et, byte[] json) + { + switch (et) + { + case "Happened": return _codec.Decode(json); + case "Compacted": return _codec.Decode(json); + default: return null; + } + } + + public static Tuple Encode(IEvent x) + { + switch (x) + { + case Happened e: return Tuple.Create("Happened", _codec.Encode(e)); + case Compacted e: return Tuple.Create("Compacted", _codec.Encode(e)); + default: return null; + } + } + } + + public class State + { + public bool Happened { get; set; } + } + + public static class Folds + { + public static State Initial = new State {Happened = false}; + + private static void Evolve(State s, IEvent x) + { + switch (x) + { + case Events.Happened e: + s.Happened = true; + break; + case Events.Compacted e: + s.Happened = e.Happened; + break; + default: throw new ArgumentOutOfRangeException(nameof(x), x, "invalid"); + } + } + + public static State Fold(State origin, IEnumerable xs) + { + var s = new State {Happened = origin.Happened}; + foreach (var x in xs) Evolve(s, x); + return s; + } + + public static bool IsOrigin(IEvent e) => e is Events.Compacted; + + public static IEvent Compact(State s) => new Events.Compacted {Happened = s.Happened}; + } + + interface ICommand + { + } + + static class Commands + { + class MakeItSo : ICommand + { + } + + public static IEnumerable Interpret(State s, ICommand x) + { + switch (x) + { + case MakeItSo c: + if (!s.Happened) yield return new Events.Happened(); + break; + default: throw new ArgumentOutOfRangeException(nameof(x), x, "invalid"); + } + } + } + + + class Handler + { + readonly EquinoxHandler _inner; + + public Handler(ILogger log, IStream stream) + { + _inner = new EquinoxHandler(Folds.Fold, log, stream); + } + + /// Execute `command`, syncing any events decided upon + public Task Execute(ICommand c) => + _inner.Decide(ctx => + ctx.Execute(s => Commands.Interpret(s, c))); + + /// Establish the present state of the Stream, project from that as specified by `projection` + public Task Query(Func projection) => + _inner.Query(projection); + } + + class View + { + public bool Sorted { get; private set; } + } + + public class Service + { + /// Maps a ClientId to Handler for the relevant stream + readonly Func _stream; + + public Service(ILogger handlerLog, Func> resolve) => + _stream = id => new Handler(handlerLog, resolve(CategoryId(id))); + + static Target CategoryId(string id) => Target.NewCatId("Aggregate", id); + + static View Render(State s) => new View() {Sorted = s.Happened}; + + /// Read the present state + // TOCONSIDER: you should probably be separating this out per CQRS and reading from a denormalized/cached set of projections + public Task Read(string id) => _stream(id).Query(Render); + + /// Execute the specified command + public Task Execute(string id, ICommand command) => + _stream(id).Execute(command); + } + } +} \ No newline at end of file diff --git a/equinox-web-csharp/Domain/Aggregate.fs b/equinox-web-csharp/Domain/Aggregate.fs deleted file mode 100755 index c8763c50d..000000000 --- a/equinox-web-csharp/Domain/Aggregate.fs +++ /dev/null @@ -1,63 +0,0 @@ -module TodoBackendTemplate.Aggregate - -// NB - these types and names reflect the actual storage formats and hence need to be versioned with care -module Events = - type Compacted = { happened: bool } - - type Event = - | Happened - | Compacted of Compacted - interface TypeShape.UnionContract.IUnionContract - -module Folds = - - type State = { happened: bool } - let initial = { happened = false } - let evolve s = function - | Events.Happened -> { happened = true } - | Events.Compacted e -> { happened = e.happened} - let fold (state : State) : Events.Event seq -> State = Seq.fold evolve state - let isOrigin = function Events.Compacted _ -> true | _ -> false - let compact state = Events.Compacted { happened = state.happened } - -module Commands = - - type Command = - | MakeItSo - - let interpret c (state : Folds.State) = - match c with - | MakeItSo -> if state.happened then [] else [Events.Happened] - -type Handler(log, stream, ?maxAttempts) = - - let inner = Equinox.Handler(Folds.fold, log, stream, maxAttempts = defaultArg maxAttempts 2) - - /// Execute `command`, syncing any events decided upon - member __.Execute command : Async = - inner.Decide <| fun ctx -> - ctx.Execute (Commands.interpret command) - /// Establish the present state of the Stream, project from that as specified by `projection` - member __.Query(projection : Folds.State -> 't) : Async<'t> = - inner.Query projection - -type View = { sorted : bool } - -type Service(handlerLog, resolve) = - - let (|CategoryId|) (id: string) = Equinox.CatId("Aggregate", id) - - /// Maps a ClientId to Handler for the relevant stream - let (|Stream|) (CategoryId catId) = Handler(handlerLog, resolve catId) - - let render (s: Folds.State) : View = - { sorted = s.happened } - - /// Read the present state - // TOCONSIDER: you should probably be separating this out per CQRS and reading from a denormalized/cached set of projections - member __.Read(Stream stream) : Async = - stream.Query (fun s -> render s) - - /// Execute the specified command - member __.Execute(Stream stream, command) : Async = - stream.Execute command \ No newline at end of file diff --git a/equinox-web-csharp/Domain/ClientId.cs b/equinox-web-csharp/Domain/ClientId.cs new file mode 100755 index 000000000..bdeaac81e --- /dev/null +++ b/equinox-web-csharp/Domain/ClientId.cs @@ -0,0 +1,36 @@ +using System; +using System.ComponentModel; +using System.Globalization; + +namespace TodoBackendTemplate +{ + /// ClientId strongly typed id + // To support model binding using aspnetcore 2 FromHeader + [TypeConverter(typeof(ClientIdStringConverter))] + public class ClientId + { + private ClientId(Guid value) => Value = value; + + public Guid Value { get; } + + // NB for validation [and XSS] purposes we must prove it translatable to a Guid + public static ClientId Parse(string input) => new ClientId(Guid.Parse(input)); + + public override string ToString() => Value.ToString("N"); + } + + class ClientIdStringConverter : TypeConverter + { + public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) => + sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); + + public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) => + value is string s ? ClientId.Parse(s) : base.ConvertFrom(context, culture, value); + + public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, + Type destinationType) => + value is ClientId c && destinationType == typeof(string) + ? c.Value + : base.ConvertTo(context, culture, value, destinationType); + } +} \ No newline at end of file diff --git a/equinox-web-csharp/Domain/ClientId.fs b/equinox-web-csharp/Domain/ClientId.fs deleted file mode 100755 index f9da4d00a..000000000 --- a/equinox-web-csharp/Domain/ClientId.fs +++ /dev/null @@ -1,43 +0,0 @@ -namespace TodoBackendTemplate - -open System -open System.Runtime.Serialization - -/// Endows any type that inherits this class with standard .NET comparison semantics using a supplied token identifier -[] -type Comparable<'TComp, 'Token when 'TComp :> Comparable<'TComp, 'Token> and 'Token : comparison>(token : 'Token) = - member private __.Token = token // I can haz protected? - override x.Equals y = match y with :? Comparable<'TComp, 'Token> as y -> x.Token = y.Token | _ -> false - override __.GetHashCode() = hash token - interface IComparable with - member x.CompareTo y = - match y with - | :? Comparable<'TComp, 'Token> as y -> compare x.Token y.Token - | _ -> invalidArg "y" "invalid comparand" - -/// ClientId strongly typed id -[] -// To support model binding using aspnetcore 2 FromHeader -[)>] -// (Internally a string for most efficient copying semantics) -type ClientId private (id : string) = - inherit Comparable(id) - [] // Prevent swashbuckle inferring there's a "value" field - member __.Value = id - override __.ToString () = id - // NB tests lean on having a ctor of this shape - new (guid: Guid) = ClientId (guid.ToString("N")) - // NB for validation [and XSS] purposes we must prove it translatable to a Guid - static member Parse(input: string) = ClientId (Guid.Parse input) -and private ClientIdStringConverter() = - inherit System.ComponentModel.TypeConverter() - override __.CanConvertFrom(context, sourceType) = - sourceType = typedefof || base.CanConvertFrom(context,sourceType) - override __.ConvertFrom(context, culture, value) = - match value with - | :? string as s -> s |> ClientId.Parse |> box - | _ -> base.ConvertFrom(context, culture, value) - override __.ConvertTo(context, culture, value, destinationType) = - match value with - | :? ClientId as value when destinationType = typedefof -> value.Value :> _ - | _ -> base.ConvertTo(context, culture, value, destinationType) \ No newline at end of file diff --git a/equinox-web-csharp/Domain/Domain.fsproj b/equinox-web-csharp/Domain/Domain.csproj similarity index 58% rename from equinox-web-csharp/Domain/Domain.fsproj rename to equinox-web-csharp/Domain/Domain.csproj index cec1a25b7..e95990b8e 100755 --- a/equinox-web-csharp/Domain/Domain.fsproj +++ b/equinox-web-csharp/Domain/Domain.csproj @@ -1,24 +1,23 @@ - - - - netstandard2.0 - 5 - false - true - - - - TRACE;DEBUG;todos - - - - - - - - - - - - + + + + netstandard2.0 + false + + + + TRACE;DEBUG;todos + + + + + + + + + + ..\..\..\..\.nuget\packages\newtonsoft.json\11.0.2\lib\netstandard2.0\Newtonsoft.Json.dll + + + \ No newline at end of file diff --git a/equinox-web-csharp/Domain/Infrastructure.cs b/equinox-web-csharp/Domain/Infrastructure.cs new file mode 100644 index 000000000..f011e4492 --- /dev/null +++ b/equinox-web-csharp/Domain/Infrastructure.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Equinox; +using Equinox.Store; +using Microsoft.FSharp.Collections; +using Microsoft.FSharp.Control; +using Microsoft.FSharp.Core; +using Newtonsoft.Json; +using Serilog; + +namespace TodoBackendTemplate +{ + public static class HandlerExtensions + { + public static void Execute(this Context that, + Func> f) => + that.Execute(FuncConvert.FromFunc>(s => ListModule.OfSeq(f(s)))); + } + + public class EquinoxHandler : Handler + { + public EquinoxHandler(Func, TState> fold, ILogger log, + IStream stream) + : base(FuncConvert.FromFunc, TState>(fold), log, stream, 3, null, null) + { + } + + public async Task Decide(Action> f) => + await FSharpAsync.StartAsTask(Decide(FuncConvert.ToFSharpFunc(f)), null, null); + + public async Task Query(Func projection) => + await FSharpAsync.StartAsTask(Query(FuncConvert.FromFunc(projection)), null, null); + } + + /// Newtonsoft.Json implementation of IEncoder that encodes direct to a UTF-8 Buffer + class JsonNetUtf8Codec + { + readonly JsonSerializer _serializer; + + public JsonNetUtf8Codec(JsonSerializerSettings settings) => + _serializer = JsonSerializer.Create(settings); + + public byte[] Encode(T value) where T : class + { + using (var ms = new MemoryStream()) + { + using (var jsonWriter = new JsonTextWriter(new StreamWriter(ms))) + _serializer.Serialize(jsonWriter, value, typeof(T)); + return ms.ToArray(); + } + } + + public T Decode(byte[] json) where T: class + { + using (var ms = new MemoryStream(json)) + using (var jsonReader = new JsonTextReader(new StreamReader(ms))) + return _serializer.Deserialize(jsonReader); + } + } + +} \ No newline at end of file diff --git a/equinox-web-csharp/Domain/TodosService.fs b/equinox-web-csharp/Domain/TodosService.cs similarity index 97% rename from equinox-web-csharp/Domain/TodosService.fs rename to equinox-web-csharp/Domain/TodosService.cs index ed58aea58..66fab28d2 100755 --- a/equinox-web-csharp/Domain/TodosService.fs +++ b/equinox-web-csharp/Domain/TodosService.cs @@ -1,131 +1,131 @@ -module TodoBackendTemplate.Todo - -// NB - these types and names reflect the actual storage formats and hence need to be versioned with care -module Events = - - /// Information we retain per Todo List entry - type ItemData = { id: int; order: int; title: string; completed: bool } - /// Events we keep in Todo-* streams - type Event = - | Added of ItemData - | Updated of ItemData - | Deleted of int - /// Cleared also `isOrigin` (see below) - if we see one of these, we know we don't need to look back any further - | Cleared - /// For EventStore, AccessStrategy.RollingSnapshots embeds these events every `batchSize` events - | Compacted of ItemData[] - interface TypeShape.UnionContract.IUnionContract - -/// Types and mapping logic used maintain relevant State based on Events observed on the Todo List Stream -module Folds = - - /// Present state of the Todo List as inferred from the Events we've seen to date - type State = { items : Events.ItemData list; nextId : int } - /// State implied by the absence of any events on this stream - let initial = { items = []; nextId = 0 } - /// Compute State change implied by a given Event - let evolve s = function - | Events.Added item -> { s with items = item :: s.items; nextId = s.nextId + 1 } - | Events.Updated value -> { s with items = s.items |> List.map (function { id = id } when id = value.id -> value | item -> item) } - | Events.Deleted id -> { s with items = s.items |> List.filter (fun x -> x.id <> id) } - | Events.Cleared -> { s with items = [] } - | Events.Compacted items -> { s with items = List.ofArray items } - /// Folds a set of events from the store into a given `state` - let fold (state : State) : Events.Event seq -> State = Seq.fold evolve state - /// Determines whether a given event represents a checkpoint that implies we don't need to see any preceding events - let isOrigin = function Events.Cleared | Events.Compacted _ -> true | _ -> false - /// Prepares an Event that encodes all relevant aspects of a State such that `evolve` can rehydrate a complete State from it - let compact state = Events.Compacted (Array.ofList state.items) - -/// Properties that can be edited on a Todo List item -type Props = { order: int; title: string; completed: bool } - -/// Defines the decion process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream -module Commands = - - /// Defines the operations a caller can perform on a Todo List - type Command = - /// Create a single item - | Add of Props - /// Update a single item - | Update of id: int * Props - /// Delete a single item from the list - | Delete of id: int - /// Complete clear the todo list - | Clear - - let interpret c (state : Folds.State) = - let mkItem id (value: Props): Events.ItemData = { id = id; order=value.order; title=value.title; completed=value.completed } - match c with - | Add value -> [Events.Added (mkItem state.nextId value)] - | Update (itemId,value) -> - let proposed = mkItem itemId value - match state.items |> List.tryFind (function { id = id } -> id = itemId) with - | Some current when current <> proposed -> [Events.Updated proposed] - | _ -> [] - | Delete id -> if state.items |> List.exists (fun x -> x.id = id) then [Events.Deleted id] else [] - | Clear -> if state.items |> List.isEmpty then [] else [Events.Cleared] - -/// Defines low level stream operations relevant to the Todo Stream in terms of Command and Events -type Handler(log, stream, ?maxAttempts) = - - let inner = Equinox.Handler(Folds.fold, log, stream, maxAttempts = defaultArg maxAttempts 2) - - /// Execute `command`; does not emit the post state - member __.Execute command : Async = - inner.Decide <| fun ctx -> - ctx.Execute (Commands.interpret command) - /// Handle `command`, return the items after the command's intent has been applied to the stream - member __.Handle command : Async = - inner.Decide <| fun ctx -> - ctx.Execute (Commands.interpret command) - ctx.State.items - /// Establish the present state of the Stream, project from that as specified by `projection` - member __.Query(projection : Folds.State -> 't) : Async<'t> = - inner.Query projection - -/// A single Item in the Todo List -type View = { id: int; order: int; title: string; completed: bool } - -/// Defines operations that a Controller can perform on a Todo List -type Service(handlerLog, resolve) = - - /// Maps a ClientId to the CatId that specifies the Stream in whehch the data for that client will be held - let (|CategoryId|) (clientId: ClientId) = Equinox.CatId("Todos", if obj.ReferenceEquals(clientId,null) then "1" else clientId.Value) - - /// Maps a ClientId to Handler for the relevant stream - let (|Stream|) (CategoryId catId) = Handler(handlerLog, resolve catId) - - let render (item: Events.ItemData) : View = - { id = item.id - order = item.order - title = item.title - completed = item.completed } - - (* READ *) - - /// List all open items - member __.List(Stream stream) : Async = - stream.Query (fun x -> seq { for x in x.items -> render x }) - - /// Load details for a single specific item - member __.TryGet(Stream stream, id) : Async = - stream.Query (fun x -> x.items |> List.tryFind (fun x -> x.id = id) |> Option.map render) - - (* WRITE *) - - /// Execute the specified (blind write) command - member __.Execute(Stream stream, command) : Async = - stream.Execute command - - (* WRITE-READ *) - - /// Create a new ToDo List item; response contains the generated `id` - member __.Create(Stream stream, template: Props) : Async = async { - let! state' = stream.Handle(Commands.Add template) - return List.head state' |> render } - - /// Update the specified item as referenced by the `item.id` - member __.Patch(Stream stream, id: int, value: Props) : Async = async { - let! state' = stream.Handle(Commands.Update (id, value)) +module TodoBackendTemplate.Todo + +// NB - these types and names reflect the actual storage formats and hence need to be versioned with care +module Events = + + /// Information we retain per Todo List entry + type ItemData = { id: int; order: int; title: string; completed: bool } + /// Events we keep in Todo-* streams + type Event = + | Added of ItemData + | Updated of ItemData + | Deleted of int + /// Cleared also `isOrigin` (see below) - if we see one of these, we know we don't need to look back any further + | Cleared + /// For EventStore, AccessStrategy.RollingSnapshots embeds these events every `batchSize` events + | Compacted of ItemData[] + interface TypeShape.UnionContract.IUnionContract + +/// Types and mapping logic used maintain relevant State based on Events observed on the Todo List Stream +module Folds = + + /// Present state of the Todo List as inferred from the Events we've seen to date + type State = { items : Events.ItemData list; nextId : int } + /// State implied by the absence of any events on this stream + let initial = { items = []; nextId = 0 } + /// Compute State change implied by a given Event + let evolve s = function + | Events.Added item -> { s with items = item :: s.items; nextId = s.nextId + 1 } + | Events.Updated value -> { s with items = s.items |> List.map (function { id = id } when id = value.id -> value | item -> item) } + | Events.Deleted id -> { s with items = s.items |> List.filter (fun x -> x.id <> id) } + | Events.Cleared -> { s with items = [] } + | Events.Compacted items -> { s with items = List.ofArray items } + /// Folds a set of events from the store into a given `state` + let fold (state : State) : Events.Event seq -> State = Seq.fold evolve state + /// Determines whether a given event represents a checkpoint that implies we don't need to see any preceding events + let isOrigin = function Events.Cleared | Events.Compacted _ -> true | _ -> false + /// Prepares an Event that encodes all relevant aspects of a State such that `evolve` can rehydrate a complete State from it + let compact state = Events.Compacted (Array.ofList state.items) + +/// Properties that can be edited on a Todo List item +type Props = { order: int; title: string; completed: bool } + +/// Defines the decion process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream +module Commands = + + /// Defines the operations a caller can perform on a Todo List + type Command = + /// Create a single item + | Add of Props + /// Update a single item + | Update of id: int * Props + /// Delete a single item from the list + | Delete of id: int + /// Complete clear the todo list + | Clear + + let interpret c (state : Folds.State) = + let mkItem id (value: Props): Events.ItemData = { id = id; order=value.order; title=value.title; completed=value.completed } + match c with + | Add value -> [Events.Added (mkItem state.nextId value)] + | Update (itemId,value) -> + let proposed = mkItem itemId value + match state.items |> List.tryFind (function { id = id } -> id = itemId) with + | Some current when current <> proposed -> [Events.Updated proposed] + | _ -> [] + | Delete id -> if state.items |> List.exists (fun x -> x.id = id) then [Events.Deleted id] else [] + | Clear -> if state.items |> List.isEmpty then [] else [Events.Cleared] + +/// Defines low level stream operations relevant to the Todo Stream in terms of Command and Events +type Handler(log, stream, ?maxAttempts) = + + let inner = Equinox.Handler(Folds.fold, log, stream, maxAttempts = defaultArg maxAttempts 2) + + /// Execute `command`; does not emit the post state + member __.Execute command : Async = + inner.Decide <| fun ctx -> + ctx.Execute (Commands.interpret command) + /// Handle `command`, return the items after the command's intent has been applied to the stream + member __.Handle command : Async = + inner.Decide <| fun ctx -> + ctx.Execute (Commands.interpret command) + ctx.State.items + /// Establish the present state of the Stream, project from that as specified by `projection` + member __.Query(projection : Folds.State -> 't) : Async<'t> = + inner.Query projection + +/// A single Item in the Todo List +type View = { id: int; order: int; title: string; completed: bool } + +/// Defines operations that a Controller can perform on a Todo List +type Service(handlerLog, resolve) = + + /// Maps a ClientId to the CatId that specifies the Stream in whehch the data for that client will be held + let (|CategoryId|) (clientId: ClientId) = Equinox.CatId("Todos", if obj.ReferenceEquals(clientId,null) then "1" else clientId.Value) + + /// Maps a ClientId to Handler for the relevant stream + let (|Stream|) (CategoryId catId) = Handler(handlerLog, resolve catId) + + let render (item: Events.ItemData) : View = + { id = item.id + order = item.order + title = item.title + completed = item.completed } + + (* READ *) + + /// List all open items + member __.List(Stream stream) : Async = + stream.Query (fun x -> seq { for x in x.items -> render x }) + + /// Load details for a single specific item + member __.TryGet(Stream stream, id) : Async = + stream.Query (fun x -> x.items |> List.tryFind (fun x -> x.id = id) |> Option.map render) + + (* WRITE *) + + /// Execute the specified (blind write) command + member __.Execute(Stream stream, command) : Async = + stream.Execute command + + (* WRITE-READ *) + + /// Create a new ToDo List item; response contains the generated `id` + member __.Create(Stream stream, template: Props) : Async = async { + let! state' = stream.Handle(Commands.Add template) + return List.head state' |> render } + + /// Update the specified item as referenced by the `item.id` + member __.Patch(Stream stream, id: int, value: Props) : Async = async { + let! state' = stream.Handle(Commands.Update (id, value)) return state' |> List.find (fun x -> x.id = id) |> render} \ No newline at end of file diff --git a/equinox-web-csharp/Web/CosmosContext.cs b/equinox-web-csharp/Web/CosmosContext.cs index 403b7c8fb..98b0f92c7 100644 --- a/equinox-web-csharp/Web/CosmosContext.cs +++ b/equinox-web-csharp/Web/CosmosContext.cs @@ -71,11 +71,10 @@ internal override void Connect() var _ = _store.Value; } - public override IStream Resolve( + public override Func> Resolve( IUnionEncoder codec, Func, TState> fold, TState initial, - Target target, Func isOrigin = null, Func compact = null) { @@ -89,7 +88,7 @@ public override IStream Resolve( ? null : CachingStrategy.NewSlidingWindow(_cache, TimeSpan.FromMinutes(20)); var resolver = new EqxResolver(_store.Value, codec, FuncConvert.FromFunc(fold), initial, accessStrategy, cacheStrategy); - return resolver.Resolve.Invoke(target); + return t => resolver.Resolve.Invoke(t); } } } \ No newline at end of file diff --git a/equinox-web-csharp/Web/EquinoxContext.cs b/equinox-web-csharp/Web/EquinoxContext.cs index 8c16c74cd..12d7c6b47 100644 --- a/equinox-web-csharp/Web/EquinoxContext.cs +++ b/equinox-web-csharp/Web/EquinoxContext.cs @@ -9,11 +9,10 @@ namespace TodoBackendTemplate { public abstract class EquinoxContext { - public abstract Equinox.Store.IStream Resolve( + public abstract Func> Resolve( Equinox.UnionCodec.IUnionEncoder codec, Func, TState> fold, TState initial, - Target target, Func isOrigin = null, Func compact = null); diff --git a/equinox-web-csharp/Web/EventStoreContext.cs b/equinox-web-csharp/Web/EventStoreContext.cs index 91d5dc5c9..24b79296c 100644 --- a/equinox-web-csharp/Web/EventStoreContext.cs +++ b/equinox-web-csharp/Web/EventStoreContext.cs @@ -58,11 +58,10 @@ internal override void Connect() var _ = _gateway.Value; } - public override IStream Resolve( + public override Func> Resolve( IUnionEncoder codec, Func, TState> fold, TState initial, - Target target, Func isOrigin = null, Func compact = null) { @@ -75,7 +74,7 @@ public override IStream Resolve( : CachingStrategy.NewSlidingWindow(_cache, TimeSpan.FromMinutes(20)); var resolver = new GesResolver(_gateway.Value, codec, FuncConvert.FromFunc(fold), initial, accessStrategy, cacheStrategy); - return resolver.Resolve.Invoke(target); + return t => resolver.Resolve.Invoke(t); } } } \ No newline at end of file diff --git a/equinox-web-csharp/Web/MemoryStoreContext.cs b/equinox-web-csharp/Web/MemoryStoreContext.cs index 651557890..c4f165256 100644 --- a/equinox-web-csharp/Web/MemoryStoreContext.cs +++ b/equinox-web-csharp/Web/MemoryStoreContext.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using Equinox; using Equinox.MemoryStore; +using Equinox.Store; using Equinox.UnionCodec; using Microsoft.FSharp.Core; @@ -15,16 +17,15 @@ public MemoryStoreContext(VolatileStore store) _store = store; } - public override Equinox.Store.IStream Resolve( + public override Func> Resolve( IUnionEncoder codec, Func, TState> fold, TState initial, - Equinox.Target target, Func isOrigin = null, Func compact = null) { var resolver = new MemResolver(_store, FuncConvert.FromFunc(fold), initial); - return resolver.Resolve.Invoke(target); + return target => resolver.Resolve.Invoke(target); } internal override void Connect() diff --git a/equinox-web-csharp/Web/Startup.cs b/equinox-web-csharp/Web/Startup.cs index c7cf61604..3de2404e2 100755 --- a/equinox-web-csharp/Web/Startup.cs +++ b/equinox-web-csharp/Web/Startup.cs @@ -130,15 +130,15 @@ public Todo.Service CreateTodosService() => Todo.Folds.compact)); #endif #if aggregate - public Todo.Service CreateAggregateService() => - Aggregate.Service( + public Aggregate.Service CreateAggregateService() => + new Aggregate.Service( _handlerLog, _context.Resolve( - EquinoxCodec.Create(), - Aggregate.Folds.fold, - Aggregate.Folds.initial, - Aggregate.Folds.isOrigin, - Aggregate.Folds.compact)); + EquinoxCodec.Create(Aggregate.Events.Encode,Aggregate.Events.TryDecode), + Aggregate.Folds.Fold, + Aggregate.Folds.Initial, + Aggregate.Folds.IsOrigin, + Aggregate.Folds.Compact)); #endif #if (!aggregate && !todos) public Thing.Service CreateAggregateService() => diff --git a/equinox-web-csharp/Web/Web.csproj b/equinox-web-csharp/Web/Web.csproj index ecec6ad7d..c831a2700 100755 --- a/equinox-web-csharp/Web/Web.csproj +++ b/equinox-web-csharp/Web/Web.csproj @@ -14,19 +14,12 @@ - - - - - - - ..\..\..\..\.nuget\packages\serilog\2.7.1\lib\netstandard1.3\Serilog.dll - + \ No newline at end of file From 1660daabd84d4fdc4bc8d09887eeb07bb82c01dd Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 4 Jan 2019 13:07:01 +0000 Subject: [PATCH 03/17] Most of Todo ported --- equinox-web-csharp/Domain/Aggregate.cs | 37 +- equinox-web-csharp/Domain/Infrastructure.cs | 11 +- equinox-web-csharp/Domain/Todo.cs | 352 ++++++++++++++++++++ equinox-web-csharp/Domain/TodosService.cs | 131 -------- equinox-web-csharp/Web/Startup.cs | 2 +- equinox-web/Domain/TodosService.fs | 19 +- 6 files changed, 385 insertions(+), 167 deletions(-) create mode 100755 equinox-web-csharp/Domain/Todo.cs delete mode 100755 equinox-web-csharp/Domain/TodosService.cs diff --git a/equinox-web-csharp/Domain/Aggregate.cs b/equinox-web-csharp/Domain/Aggregate.cs index 6f2fcda66..28da90a65 100755 --- a/equinox-web-csharp/Domain/Aggregate.cs +++ b/equinox-web-csharp/Domain/Aggregate.cs @@ -1,15 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Data.SqlTypes; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.ExceptionServices; -using System.Threading.Tasks; -using Equinox; +using Equinox; using Equinox.Store; -using Microsoft.FSharp.Collections; using Microsoft.FSharp.Core; using Newtonsoft.Json; using Serilog; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; namespace TodoBackendTemplate { @@ -31,14 +27,14 @@ public class Compacted : IEvent public bool Happened { get; set; } } - static JsonNetUtf8Codec _codec = new JsonNetUtf8Codec(new JsonSerializerSettings()); + private static readonly JsonNetUtf8Codec Codec = new JsonNetUtf8Codec(new JsonSerializerSettings()); public static IEvent TryDecode(string et, byte[] json) { switch (et) { - case "Happened": return _codec.Decode(json); - case "Compacted": return _codec.Decode(json); + case nameof(Happened): return Codec.Decode(json); + case nameof(Compacted): return Codec.Decode(json); default: return null; } } @@ -47,8 +43,8 @@ public static Tuple Encode(IEvent x) { switch (x) { - case Happened e: return Tuple.Create("Happened", _codec.Encode(e)); - case Compacted e: return Tuple.Create("Compacted", _codec.Encode(e)); + case Happened e: return Tuple.Create(nameof(Happened), Codec.Encode(e)); + case Compacted e: return Tuple.Create(nameof(Compacted), Codec.Encode(e)); default: return null; } } @@ -61,7 +57,7 @@ public class State public static class Folds { - public static State Initial = new State {Happened = false}; + public static readonly State Initial = new State {Happened = false}; private static void Evolve(State s, IEvent x) { @@ -89,13 +85,14 @@ public static State Fold(State origin, IEnumerable xs) public static IEvent Compact(State s) => new Events.Compacted {Happened = s.Happened}; } - interface ICommand + public interface ICommand { } - static class Commands + /// Defines the decision process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream + public static class Commands { - class MakeItSo : ICommand + public class MakeItSo : ICommand { } @@ -112,7 +109,7 @@ public static IEnumerable Interpret(State s, ICommand x) } - class Handler + private class Handler { readonly EquinoxHandler _inner; @@ -131,9 +128,9 @@ public Task Query(Func projection) => _inner.Query(projection); } - class View + public class View { - public bool Sorted { get; private set; } + public bool Sorted { get; set; } } public class Service diff --git a/equinox-web-csharp/Domain/Infrastructure.cs b/equinox-web-csharp/Domain/Infrastructure.cs index f011e4492..1fdd446d8 100644 --- a/equinox-web-csharp/Domain/Infrastructure.cs +++ b/equinox-web-csharp/Domain/Infrastructure.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; using Equinox; using Equinox.Store; using Microsoft.FSharp.Collections; @@ -9,6 +5,10 @@ using Microsoft.FSharp.Core; using Newtonsoft.Json; using Serilog; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; namespace TodoBackendTemplate { @@ -35,7 +35,7 @@ public async Task Query(Func projection) => } /// Newtonsoft.Json implementation of IEncoder that encodes direct to a UTF-8 Buffer - class JsonNetUtf8Codec + public class JsonNetUtf8Codec { readonly JsonSerializer _serializer; @@ -59,5 +59,4 @@ public T Decode(byte[] json) where T: class return _serializer.Deserialize(jsonReader); } } - } \ No newline at end of file diff --git a/equinox-web-csharp/Domain/Todo.cs b/equinox-web-csharp/Domain/Todo.cs new file mode 100755 index 000000000..5c8600317 --- /dev/null +++ b/equinox-web-csharp/Domain/Todo.cs @@ -0,0 +1,352 @@ +using Equinox; +using Equinox.Store; +using Microsoft.FSharp.Core; +using Newtonsoft.Json; +using Serilog; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data.SqlTypes; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.FSharp.Collections; + +namespace TodoBackendTemplate +{ + public class Todo + { + public interface IEvent + { + } + + /// NB - these types and names reflect the actual storage formats and hence need to be versioned with care + public static class Events + { + /// Information we retain per Todo List entry + public abstract class ItemData + { + public int Id { get; set; } + public int Order { get; set; } + public string Title { get; set; } + public bool Completed { get; set; } + } + + public class Added : ItemData, IEvent + { + } + + public class Updated : ItemData, IEvent + { + } + + public class Deleted: IEvent + { + public int Id { get; set; } + } + + public class Cleared: IEvent + { + public int NextId { get; set; } + } + + public class Compacted: IEvent + { + public int NextId { get; set; } + public ItemData[] Items { get; set; } + } + + private static readonly JsonNetUtf8Codec Codec = new JsonNetUtf8Codec(new JsonSerializerSettings()); + + public static IEvent TryDecode(string et, byte[] json) + { + switch (et) + { + case nameof(Added): return Codec.Decode(json); + case nameof(Updated): return Codec.Decode(json); + case nameof(Deleted): return Codec.Decode(json); + case nameof(Cleared): return Codec.Decode(json); + case nameof(Compacted): return Codec.Decode(json); + default: return null; + } + } + + public static Tuple Encode(IEvent x) + { + switch (x) + { + case Added e: return Tuple.Create(nameof(Added), Codec.Encode(e)); + case Updated e: return Tuple.Create(nameof(Updated), Codec.Encode(e)); + case Deleted e: return Tuple.Create(nameof(Deleted), Codec.Encode(e)); + case Cleared e: return Tuple.Create(nameof(Cleared), Codec.Encode(e)); + case Compacted e: return Tuple.Create(nameof(Compacted), Codec.Encode(e)); + default: return null; + } + } + } + + /// Present state of the Todo List as inferred from the Events we've seen to date + // NB the value of the state is only ever manipulated in a cloned copy within Fold() + // This is critical for caching and/or concurrent transactions to work correctly + // In the F# impl, this is achieved by virtue of the fact that records and [F#] lists represent + // persistent data structures https://en.wikipedia.org/wiki/Persistent_data_structure + public class State + { + public int NextId { get; } + public Events.ItemData[] Items { get; } + + public State(int nextId, Events.ItemData[] items) + { + NextId = nextId; + Items = items; + } + } + + public static class Folds + { + public static State Initial = new State(0, new Events.ItemData[0]); + + /// Folds a set of events from the store into a given `state` + public static State Fold(State origin, IEnumerable xs) + { + var nextId = origin.NextId; + var items = origin.Items.ToList(); + foreach (var x in xs) + switch (x) + { + case Events.Added e: + nextId++; + items.Insert(0, e); + break; + case Events.Updated e: + var i = items.FindIndex(item => item.Id == e.Id); + if (i != -1) + items[i] = e; + break; + case Events.Deleted e: + items.RemoveAll(item => item.Id == e.Id); + break; + case Events.Cleared e: + nextId = e.NextId; + items.Clear(); + break; + case Events.Compacted e: + nextId = e.NextId; + items = e.Items.ToList(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(x), x, "invalid"); + } + return new State(nextId, items.ToArray()); + } + } + + /// Properties that can be edited on a Todo List item + public class Props + { + public int Order { get; set; } + public string Title { get; set; } + public bool Completed { get; set; } + } + + /// Defines the operations a caller can perform on a Todo List + public interface ICommand + { + } + + /// Defines the decision process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream + public static class Commands + { + /// Create a single item + public class Add : ICommand + { + public Props Props { get; set; } + } + + /// Update a single item + public class Update : ICommand + { + public int Id { get; set; } + public Props Props { get; set; } + } + + /// Delete a single item from the list + public class Delete : ICommand + { + public int Id { get; set; } + } + + /// Complete clear the todo list + public class Clear : ICommand + { + } + + public static IEnumerable Interpret(State s, ICommand x) + { + switch (x) + { + case Add c: + yield return Make(s.NextId, c.Props); + break; + case Update c: + var proposed = Tuple.Create(c.Props.Order, c.Props.Title, c.Props.Completed); + + bool IsEquivalent(Events.ItemData i) => + i.Id == c.Id && Tuple.Create(i.Order, i.Title, i.Completed).Equals(proposed); + + if (!s.Items.Any(IsEquivalent)) + yield return Make(c.Id, c.Props); + break; + case Delete c: + if (s.Items.Any(i => i.Id == c.Id)) + yield return new Events.Deleted {Id = c.Id}; + break; + case Clear c: + if (s.Items.Any()) yield return new Events.Cleared {NextId = s.NextId}; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(x), x, "invalid"); + } + + T Make(int id, Props value) where T : Events.ItemData, IEvent, new() => + new T() {Id = id, Order = value.Order, Title = value.Title, Completed = value.Completed}; + } + } + + private class Handler + { + readonly EquinoxHandler _inner; + + public Handler(ILogger log, IStream stream) + { + _inner = new EquinoxHandler(Folds.Fold, log, stream); + } + + /// Execute `command`, syncing any events decided upon + public Task Execute(ICommand c) => + _inner.Decide(ctx => + ctx.Execute(s => Commands.Interpret(s, c))); + + /// Establish the present state of the Stream, project from that as specified by `projection` + public Task Query(Func projection) => + _inner.Query(projection); + } + + public class View + { + public bool Sorted { get; set; } + } + + public class Service + { + /// Maps a ClientId to Handler for the relevant stream + readonly Func _stream; + + public Service(ILogger handlerLog, Func> resolve) => + _stream = id => new Handler(handlerLog, resolve(CategoryId(id))); + + static Target CategoryId(string id) => Target.NewCatId("Todo", id); + + static View Render(State s) => new View() {Sorted = s.Happened}; + + /// Read the present state + // TOCONSIDER: you should probably be separating this out per CQRS and reading from a denormalized/cached set of projections + public Task Read(string id) => _stream(id).Query(Render); + + /// Execute the specified command + public Task Execute(string id, ICommand command) => + _stream(id).Execute(command); + } + } +} + + +/// Defines the decion process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream +module Commands = + + /// Defines the operations a caller can perform on a Todo List + type Command = + /// Create a single item + | Add of Props + /// Update a single item + | Update of id: int * Props + /// Delete a single item from the list + | Delete of id: int + /// Complete clear the todo list + | Clear + + let interpret c (state : Folds.State) = + let mkItem id (value: Props): Events.ItemData = { id = id; order=value.order; title=value.title; completed=value.completed } + match c with + | Add value -> [Events.Added (mkItem state.nextId value)] + | Update (itemId,value) -> + let proposed = mkItem itemId value + match state.items |> List.tryFind (function { id = id } -> id = itemId) with + | Some current when current <> proposed -> [Events.Updated proposed] + | _ -> [] + | Delete id -> if state.items |> List.exists (fun x -> x.id = id) then [Events.Deleted id] else [] + | Clear -> if state.items |> List.isEmpty then [] else [Events.Cleared] + +/// Defines low level stream operations relevant to the Todo Stream in terms of Command and Events +type Handler(log, stream, ?maxAttempts) = + + let inner = Equinox.Handler(Folds.fold, log, stream, maxAttempts = defaultArg maxAttempts 2) + + /// Execute `command`; does not emit the post state + member __.Execute command : Async = + inner.Decide <| fun ctx -> + ctx.Execute (Commands.interpret command) + /// Handle `command`, return the items after the command's intent has been applied to the stream + member __.Handle command : Async = + inner.Decide <| fun ctx -> + ctx.Execute (Commands.interpret command) + ctx.State.items + /// Establish the present state of the Stream, project from that as specified by `projection` + member __.Query(projection : Folds.State -> 't) : Async<'t> = + inner.Query projection + +/// A single Item in the Todo List +type View = { id: int; order: int; title: string; completed: bool } + +/// Defines operations that a Controller can perform on a Todo List +type Service(handlerLog, resolve) = + + /// Maps a ClientId to the CatId that specifies the Stream in whehch the data for that client will be held + let (|CategoryId|) (clientId: ClientId) = Equinox.CatId("Todos", if obj.ReferenceEquals(clientId,null) then "1" else clientId.Value) + + /// Maps a ClientId to Handler for the relevant stream + let (|Stream|) (CategoryId catId) = Handler(handlerLog, resolve catId) + + let render (item: Events.ItemData) : View = + { id = item.id + order = item.order + title = item.title + completed = item.completed } + + (* READ *) + + /// List all open items + member __.List(Stream stream) : Async = + stream.Query (fun x -> seq { for x in x.items -> render x }) + + /// Load details for a single specific item + member __.TryGet(Stream stream, id) : Async = + stream.Query (fun x -> x.items |> List.tryFind (fun x -> x.id = id) |> Option.map render) + + (* WRITE *) + + /// Execute the specified (blind write) command + member __.Execute(Stream stream, command) : Async = + stream.Execute command + + (* WRITE-READ *) + + /// Create a new ToDo List item; response contains the generated `id` + member __.Create(Stream stream, template: Props) : Async = async { + let! state' = stream.Handle(Commands.Add template) + return List.head state' |> render } + + /// Update the specified item as referenced by the `item.id` + member __.Patch(Stream stream, id: int, value: Props) : Async = async { + let! state' = stream.Handle(Commands.Update (id, value)) + return state' |> List.find (fun x -> x.id = id) |> render} \ No newline at end of file diff --git a/equinox-web-csharp/Domain/TodosService.cs b/equinox-web-csharp/Domain/TodosService.cs deleted file mode 100755 index 66fab28d2..000000000 --- a/equinox-web-csharp/Domain/TodosService.cs +++ /dev/null @@ -1,131 +0,0 @@ -module TodoBackendTemplate.Todo - -// NB - these types and names reflect the actual storage formats and hence need to be versioned with care -module Events = - - /// Information we retain per Todo List entry - type ItemData = { id: int; order: int; title: string; completed: bool } - /// Events we keep in Todo-* streams - type Event = - | Added of ItemData - | Updated of ItemData - | Deleted of int - /// Cleared also `isOrigin` (see below) - if we see one of these, we know we don't need to look back any further - | Cleared - /// For EventStore, AccessStrategy.RollingSnapshots embeds these events every `batchSize` events - | Compacted of ItemData[] - interface TypeShape.UnionContract.IUnionContract - -/// Types and mapping logic used maintain relevant State based on Events observed on the Todo List Stream -module Folds = - - /// Present state of the Todo List as inferred from the Events we've seen to date - type State = { items : Events.ItemData list; nextId : int } - /// State implied by the absence of any events on this stream - let initial = { items = []; nextId = 0 } - /// Compute State change implied by a given Event - let evolve s = function - | Events.Added item -> { s with items = item :: s.items; nextId = s.nextId + 1 } - | Events.Updated value -> { s with items = s.items |> List.map (function { id = id } when id = value.id -> value | item -> item) } - | Events.Deleted id -> { s with items = s.items |> List.filter (fun x -> x.id <> id) } - | Events.Cleared -> { s with items = [] } - | Events.Compacted items -> { s with items = List.ofArray items } - /// Folds a set of events from the store into a given `state` - let fold (state : State) : Events.Event seq -> State = Seq.fold evolve state - /// Determines whether a given event represents a checkpoint that implies we don't need to see any preceding events - let isOrigin = function Events.Cleared | Events.Compacted _ -> true | _ -> false - /// Prepares an Event that encodes all relevant aspects of a State such that `evolve` can rehydrate a complete State from it - let compact state = Events.Compacted (Array.ofList state.items) - -/// Properties that can be edited on a Todo List item -type Props = { order: int; title: string; completed: bool } - -/// Defines the decion process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream -module Commands = - - /// Defines the operations a caller can perform on a Todo List - type Command = - /// Create a single item - | Add of Props - /// Update a single item - | Update of id: int * Props - /// Delete a single item from the list - | Delete of id: int - /// Complete clear the todo list - | Clear - - let interpret c (state : Folds.State) = - let mkItem id (value: Props): Events.ItemData = { id = id; order=value.order; title=value.title; completed=value.completed } - match c with - | Add value -> [Events.Added (mkItem state.nextId value)] - | Update (itemId,value) -> - let proposed = mkItem itemId value - match state.items |> List.tryFind (function { id = id } -> id = itemId) with - | Some current when current <> proposed -> [Events.Updated proposed] - | _ -> [] - | Delete id -> if state.items |> List.exists (fun x -> x.id = id) then [Events.Deleted id] else [] - | Clear -> if state.items |> List.isEmpty then [] else [Events.Cleared] - -/// Defines low level stream operations relevant to the Todo Stream in terms of Command and Events -type Handler(log, stream, ?maxAttempts) = - - let inner = Equinox.Handler(Folds.fold, log, stream, maxAttempts = defaultArg maxAttempts 2) - - /// Execute `command`; does not emit the post state - member __.Execute command : Async = - inner.Decide <| fun ctx -> - ctx.Execute (Commands.interpret command) - /// Handle `command`, return the items after the command's intent has been applied to the stream - member __.Handle command : Async = - inner.Decide <| fun ctx -> - ctx.Execute (Commands.interpret command) - ctx.State.items - /// Establish the present state of the Stream, project from that as specified by `projection` - member __.Query(projection : Folds.State -> 't) : Async<'t> = - inner.Query projection - -/// A single Item in the Todo List -type View = { id: int; order: int; title: string; completed: bool } - -/// Defines operations that a Controller can perform on a Todo List -type Service(handlerLog, resolve) = - - /// Maps a ClientId to the CatId that specifies the Stream in whehch the data for that client will be held - let (|CategoryId|) (clientId: ClientId) = Equinox.CatId("Todos", if obj.ReferenceEquals(clientId,null) then "1" else clientId.Value) - - /// Maps a ClientId to Handler for the relevant stream - let (|Stream|) (CategoryId catId) = Handler(handlerLog, resolve catId) - - let render (item: Events.ItemData) : View = - { id = item.id - order = item.order - title = item.title - completed = item.completed } - - (* READ *) - - /// List all open items - member __.List(Stream stream) : Async = - stream.Query (fun x -> seq { for x in x.items -> render x }) - - /// Load details for a single specific item - member __.TryGet(Stream stream, id) : Async = - stream.Query (fun x -> x.items |> List.tryFind (fun x -> x.id = id) |> Option.map render) - - (* WRITE *) - - /// Execute the specified (blind write) command - member __.Execute(Stream stream, command) : Async = - stream.Execute command - - (* WRITE-READ *) - - /// Create a new ToDo List item; response contains the generated `id` - member __.Create(Stream stream, template: Props) : Async = async { - let! state' = stream.Handle(Commands.Add template) - return List.head state' |> render } - - /// Update the specified item as referenced by the `item.id` - member __.Patch(Stream stream, id: int, value: Props) : Async = async { - let! state' = stream.Handle(Commands.Update (id, value)) - return state' |> List.find (fun x -> x.id = id) |> render} \ No newline at end of file diff --git a/equinox-web-csharp/Web/Startup.cs b/equinox-web-csharp/Web/Startup.cs index 3de2404e2..5bf8a5948 100755 --- a/equinox-web-csharp/Web/Startup.cs +++ b/equinox-web-csharp/Web/Startup.cs @@ -119,7 +119,7 @@ public ServiceBuilder(EquinoxContext context, ILogger handlerLog) } #if todos - public Todo.Service CreateTodosService() => + public Todo.Service CreateTodoService() => Todo.Service( _handlerLog, _context.Resolve( diff --git a/equinox-web/Domain/TodosService.fs b/equinox-web/Domain/TodosService.fs index 66fab28d2..12a34609b 100644 --- a/equinox-web/Domain/TodosService.fs +++ b/equinox-web/Domain/TodosService.fs @@ -9,12 +9,13 @@ module Events = type Event = | Added of ItemData | Updated of ItemData - | Deleted of int + | Deleted of id: int /// Cleared also `isOrigin` (see below) - if we see one of these, we know we don't need to look back any further - | Cleared + | Cleared of nextId: int /// For EventStore, AccessStrategy.RollingSnapshots embeds these events every `batchSize` events - | Compacted of ItemData[] + | Compacted of CompactedData interface TypeShape.UnionContract.IUnionContract + and CompactedData = { nextId: int; items: ItemData[] } /// Types and mapping logic used maintain relevant State based on Events observed on the Todo List Stream module Folds = @@ -28,19 +29,19 @@ module Folds = | Events.Added item -> { s with items = item :: s.items; nextId = s.nextId + 1 } | Events.Updated value -> { s with items = s.items |> List.map (function { id = id } when id = value.id -> value | item -> item) } | Events.Deleted id -> { s with items = s.items |> List.filter (fun x -> x.id <> id) } - | Events.Cleared -> { s with items = [] } - | Events.Compacted items -> { s with items = List.ofArray items } + | Events.Cleared nextId -> { nextId = nextId; items = [] } + | Events.Compacted s -> { nextId = s.nextId; items = List.ofArray s.items } /// Folds a set of events from the store into a given `state` let fold (state : State) : Events.Event seq -> State = Seq.fold evolve state /// Determines whether a given event represents a checkpoint that implies we don't need to see any preceding events - let isOrigin = function Events.Cleared | Events.Compacted _ -> true | _ -> false + let isOrigin = function Events.Cleared _ | Events.Compacted _ -> true | _ -> false /// Prepares an Event that encodes all relevant aspects of a State such that `evolve` can rehydrate a complete State from it - let compact state = Events.Compacted (Array.ofList state.items) + let compact state = Events.Compacted { nextId = state.nextId; items = Array.ofList state.items } /// Properties that can be edited on a Todo List item type Props = { order: int; title: string; completed: bool } -/// Defines the decion process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream +/// Defines the decision process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream module Commands = /// Defines the operations a caller can perform on a Todo List @@ -64,7 +65,7 @@ module Commands = | Some current when current <> proposed -> [Events.Updated proposed] | _ -> [] | Delete id -> if state.items |> List.exists (fun x -> x.id = id) then [Events.Deleted id] else [] - | Clear -> if state.items |> List.isEmpty then [] else [Events.Cleared] + | Clear -> if state.items |> List.isEmpty then [] else [Events.Cleared state.nextId] /// Defines low level stream operations relevant to the Todo Stream in terms of Command and Events type Handler(log, stream, ?maxAttempts) = From 0c9e66a4a4933794b3f3017f40c7617699129c03 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 4 Jan 2019 13:44:42 +0000 Subject: [PATCH 04/17] Add C# readme --- equinox-web-csharp/README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 equinox-web-csharp/README.md diff --git a/equinox-web-csharp/README.md b/equinox-web-csharp/README.md new file mode 100644 index 000000000..ea69b5d6d --- /dev/null +++ b/equinox-web-csharp/README.md @@ -0,0 +1,32 @@ +# Equinox Web Template + +This project was generated using: + + dotnet new -i Equinox.Templates # just once, to install in the local templates store + + dotnet new equinoxweb -t --language C# # use --help to see options regarding storage subsystem configuration etc + +To run a local instance of the Website on https://localhost:5001 and http://localhost:5000 + + dotnet run -p Web + +---- + +**It's strongly recommended to also generate an F# version too - the translation of the template is intentionally not 1:1, and has not recieved as much love and attention as the F# implementation by any stretch and should definitely not be considered the cleanest C# implementation possible. The time investment of looking at both is very likely to pay off both in terms of understanding the goals/patterns of Equinox and troubleshooting** + +_Finally: Please raise issues for any and all things that the Todo sample's implementation raises. While event sourcing is pretty debatable for a todo app, and the todobackend mandates a particular implementation, that does not mean it can't be improved. Yes, even by folks like you with Imposter Syndrome!_ + +---- + +To exercise the functionality of the sample TodoBackend (included because of the `-t` in the abvoe), you can use the community project https://todobackend.com to drive the backend. _NB Jet does now own, control or audit https://todobackend.com; it is a third party site; please satisfy yourself that this is a safe thing use in your environment before using it._ + +0. The generated code includes a CORS whitelisting for https://todobackend.com. _Cors configuration should be considered holistically in the overall design of an app - Equinox itself has no requirement of any specific configuration; you should ensure appropriate care and attention is paid to this aspect of securiting your application as normal_. + +1. Run the API compliance test suite (can be useful to isolate issues if the application is experiencing internal errors): + + start https://www.todobackend.com/specs/index.html?https://localhost:5001/todos + +2. Once you've confirmed that the backend is listening and fulfulling the API obligations, you can run the frontend app: + + # Interactive UI; NB error handling is pretty minimal, so hitting refresh and/or F12 is recommended ;) + start https://www.todobackend.com/client/index.html?https://localhost:5001/todos \ No newline at end of file From 310f5657fa65972d7fb8b787aec8c98f0319ae39 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 4 Jan 2019 14:38:40 +0000 Subject: [PATCH 05/17] Implement Todo Handler and related wiring --- equinox-web-csharp/Domain/Infrastructure.cs | 28 +++++++--- equinox-web-csharp/Domain/Todo.cs | 60 +++++---------------- 2 files changed, 34 insertions(+), 54 deletions(-) diff --git a/equinox-web-csharp/Domain/Infrastructure.cs b/equinox-web-csharp/Domain/Infrastructure.cs index 1fdd446d8..ce2ad03a6 100644 --- a/equinox-web-csharp/Domain/Infrastructure.cs +++ b/equinox-web-csharp/Domain/Infrastructure.cs @@ -21,17 +21,31 @@ public static void Execute(this Context that, public class EquinoxHandler : Handler { - public EquinoxHandler(Func, TState> fold, ILogger log, - IStream stream) - : base(FuncConvert.FromFunc, TState>(fold), log, stream, 3, null, null) + public EquinoxHandler + ( Func, TState> fold, + ILogger log, + IStream stream, + int maxAttempts = 3) + : base(FuncConvert.FromFunc, TState>(fold), + log, + stream, + maxAttempts, + null, + null) { } - public async Task Decide(Action> f) => - await FSharpAsync.StartAsTask(Decide(FuncConvert.ToFSharpFunc(f)), null, null); + // Run the decision method, letting it decide whether or not the Command's intent should manifest as Events + public async Task Decide(Action> decide) => + await FSharpAsync.StartAsTask(Decide(FuncConvert.ToFSharpFunc(decide)), null, null); - public async Task Query(Func projection) => - await FSharpAsync.StartAsTask(Query(FuncConvert.FromFunc(projection)), null, null); + // Execute a command, as Decide(Action) does, but also yield an outcome from the decision + public async Task Decide(Func,T> interpret) => + await FSharpAsync.StartAsTask(Decide(FuncConvert.FromFunc(interpret)), null, null); + + // Project from the synchronized state, without the possibility of adding events that Decide(Func) admits + public async Task Query(Func project) => + await FSharpAsync.StartAsTask(Query(FuncConvert.FromFunc(project)), null, null); } /// Newtonsoft.Json implementation of IEncoder that encodes direct to a UTF-8 Buffer diff --git a/equinox-web-csharp/Domain/Todo.cs b/equinox-web-csharp/Domain/Todo.cs index 5c8600317..1de014d32 100755 --- a/equinox-web-csharp/Domain/Todo.cs +++ b/equinox-web-csharp/Domain/Todo.cs @@ -189,10 +189,9 @@ public static IEnumerable Interpret(State s, ICommand x) break; case Update c: var proposed = Tuple.Create(c.Props.Order, c.Props.Title, c.Props.Completed); - bool IsEquivalent(Events.ItemData i) => - i.Id == c.Id && Tuple.Create(i.Order, i.Title, i.Completed).Equals(proposed); - + i.Id == c.Id + && Tuple.Create(i.Order, i.Title, i.Completed).Equals(proposed); if (!s.Items.Any(IsEquivalent)) yield return Make(c.Id, c.Props); break; @@ -213,6 +212,7 @@ bool IsEquivalent(Events.ItemData i) => } } + /// Defines low level stream operations relevant to the Todo Stream in terms of Command and Events private class Handler { readonly EquinoxHandler _inner; @@ -222,11 +222,19 @@ public Handler(ILogger log, IStream stream) _inner = new EquinoxHandler(Folds.Fold, log, stream); } - /// Execute `command`, syncing any events decided upon + /// Execute `command`; does not emit the post state public Task Execute(ICommand c) => _inner.Decide(ctx => ctx.Execute(s => Commands.Interpret(s, c))); + /// Handle `command`, return the items after the command's intent has been applied to the stream + public Task Decide(ICommand c) => + _inner.Decide(ctx => + { + ctx.Execute(s => Commands.Interpret(s, c)); + return ctx.State.Items; + }); + /// Establish the present state of the Stream, project from that as specified by `projection` public Task Query(Func projection) => _inner.Query(projection); @@ -261,49 +269,7 @@ public Task Execute(string id, ICommand command) => } -/// Defines the decion process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream -module Commands = - - /// Defines the operations a caller can perform on a Todo List - type Command = - /// Create a single item - | Add of Props - /// Update a single item - | Update of id: int * Props - /// Delete a single item from the list - | Delete of id: int - /// Complete clear the todo list - | Clear - - let interpret c (state : Folds.State) = - let mkItem id (value: Props): Events.ItemData = { id = id; order=value.order; title=value.title; completed=value.completed } - match c with - | Add value -> [Events.Added (mkItem state.nextId value)] - | Update (itemId,value) -> - let proposed = mkItem itemId value - match state.items |> List.tryFind (function { id = id } -> id = itemId) with - | Some current when current <> proposed -> [Events.Updated proposed] - | _ -> [] - | Delete id -> if state.items |> List.exists (fun x -> x.id = id) then [Events.Deleted id] else [] - | Clear -> if state.items |> List.isEmpty then [] else [Events.Cleared] - -/// Defines low level stream operations relevant to the Todo Stream in terms of Command and Events -type Handler(log, stream, ?maxAttempts) = - - let inner = Equinox.Handler(Folds.fold, log, stream, maxAttempts = defaultArg maxAttempts 2) - - /// Execute `command`; does not emit the post state - member __.Execute command : Async = - inner.Decide <| fun ctx -> - ctx.Execute (Commands.interpret command) - /// Handle `command`, return the items after the command's intent has been applied to the stream - member __.Handle command : Async = - inner.Decide <| fun ctx -> - ctx.Execute (Commands.interpret command) - ctx.State.items - /// Establish the present state of the Stream, project from that as specified by `projection` - member __.Query(projection : Folds.State -> 't) : Async<'t> = - inner.Query projection + /// A single Item in the Todo List type View = { id: int; order: int; title: string; completed: bool } From 15a2d2791c1b76a4e9e6e5c4f01bb9569d05f3b6 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 4 Jan 2019 15:53:05 +0000 Subject: [PATCH 06/17] Complete porting of Todo Service --- equinox-web-csharp/Domain/Aggregate.cs | 2 +- equinox-web-csharp/Domain/Infrastructure.cs | 2 +- equinox-web-csharp/Domain/Todo.cs | 131 ++++++++++---------- equinox-web/Domain/TodosService.fs | 2 +- 4 files changed, 66 insertions(+), 71 deletions(-) diff --git a/equinox-web-csharp/Domain/Aggregate.cs b/equinox-web-csharp/Domain/Aggregate.cs index 28da90a65..0a4d3c82d 100755 --- a/equinox-web-csharp/Domain/Aggregate.cs +++ b/equinox-web-csharp/Domain/Aggregate.cs @@ -120,7 +120,7 @@ public Handler(ILogger log, IStream stream) /// Execute `command`, syncing any events decided upon public Task Execute(ICommand c) => - _inner.Decide(ctx => + _inner.Execute(ctx => ctx.Execute(s => Commands.Interpret(s, c))); /// Establish the present state of the Stream, project from that as specified by `projection` diff --git a/equinox-web-csharp/Domain/Infrastructure.cs b/equinox-web-csharp/Domain/Infrastructure.cs index ce2ad03a6..5b98cc396 100644 --- a/equinox-web-csharp/Domain/Infrastructure.cs +++ b/equinox-web-csharp/Domain/Infrastructure.cs @@ -36,7 +36,7 @@ public EquinoxHandler } // Run the decision method, letting it decide whether or not the Command's intent should manifest as Events - public async Task Decide(Action> decide) => + public async Task Execute(Action> decide) => await FSharpAsync.StartAsTask(Decide(FuncConvert.ToFSharpFunc(decide)), null, null); // Execute a command, as Decide(Action) does, but also yield an outcome from the decision diff --git a/equinox-web-csharp/Domain/Todo.cs b/equinox-web-csharp/Domain/Todo.cs index 1de014d32..b70744ccc 100755 --- a/equinox-web-csharp/Domain/Todo.cs +++ b/equinox-web-csharp/Domain/Todo.cs @@ -34,29 +34,29 @@ public abstract class ItemData public class Added : ItemData, IEvent { } - + public class Updated : ItemData, IEvent { } - public class Deleted: IEvent + public class Deleted : IEvent { public int Id { get; set; } } - public class Cleared: IEvent + public class Cleared : IEvent { public int NextId { get; set; } } - - public class Compacted: IEvent + + public class Compacted : IEvent { public int NextId { get; set; } public ItemData[] Items { get; set; } } private static readonly JsonNetUtf8Codec Codec = new JsonNetUtf8Codec(new JsonSerializerSettings()); - + public static IEvent TryDecode(string et, byte[] json) { switch (et) @@ -69,8 +69,8 @@ public static IEvent TryDecode(string et, byte[] json) default: return null; } } - - public static Tuple Encode(IEvent x) + + public static Tuple Encode(IEvent x) { switch (x) { @@ -110,7 +110,7 @@ public static State Fold(State origin, IEnumerable xs) { var nextId = origin.NextId; var items = origin.Items.ToList(); - foreach (var x in xs) + foreach (var x in xs) switch (x) { case Events.Added e: @@ -147,7 +147,7 @@ public class Props public string Title { get; set; } public bool Completed { get; set; } } - + /// Defines the operations a caller can perform on a Todo List public interface ICommand { @@ -189,9 +189,11 @@ public static IEnumerable Interpret(State s, ICommand x) break; case Update c: var proposed = Tuple.Create(c.Props.Order, c.Props.Title, c.Props.Completed); + bool IsEquivalent(Events.ItemData i) => i.Id == c.Id && Tuple.Create(i.Order, i.Title, i.Completed).Equals(proposed); + if (!s.Items.Any(IsEquivalent)) yield return Make(c.Id, c.Props); break; @@ -224,7 +226,7 @@ public Handler(ILogger log, IStream stream) /// Execute `command`; does not emit the post state public Task Execute(ICommand c) => - _inner.Decide(ctx => + _inner.Execute(ctx => ctx.Execute(s => Commands.Interpret(s, c))); /// Handle `command`, return the items after the command's intent has been applied to the stream @@ -234,85 +236,78 @@ public Task Execute(ICommand c) => ctx.Execute(s => Commands.Interpret(s, c)); return ctx.State.Items; }); - + /// Establish the present state of the Stream, project from that as specified by `projection` public Task Query(Func projection) => _inner.Query(projection); } + /// A single Item in the Todo List public class View { - public bool Sorted { get; set; } + public int Id { get; set; } + public int Order { get; set; } + public string Title { get; set; } + public bool Completed { get; set; } } + /// Defines operations that a Controller can perform on a Todo List public class Service { /// Maps a ClientId to Handler for the relevant stream - readonly Func _stream; + readonly Func _stream; public Service(ILogger handlerLog, Func> resolve) => _stream = id => new Handler(handlerLog, resolve(CategoryId(id))); - static Target CategoryId(string id) => Target.NewCatId("Todo", id); - - static View Render(State s) => new View() {Sorted = s.Happened}; - - /// Read the present state - // TOCONSIDER: you should probably be separating this out per CQRS and reading from a denormalized/cached set of projections - public Task Read(string id) => _stream(id).Query(Render); - - /// Execute the specified command - public Task Execute(string id, ICommand command) => - _stream(id).Execute(command); - } - } -} - - + // + // READ + // + /// List all open items + public Task> List(ClientId clientId) => + _stream(clientId).Query(s => s.Items.Select(Render)); -/// A single Item in the Todo List -type View = { id: int; order: int; title: string; completed: bool } - -/// Defines operations that a Controller can perform on a Todo List -type Service(handlerLog, resolve) = - - /// Maps a ClientId to the CatId that specifies the Stream in whehch the data for that client will be held - let (|CategoryId|) (clientId: ClientId) = Equinox.CatId("Todos", if obj.ReferenceEquals(clientId,null) then "1" else clientId.Value) - - /// Maps a ClientId to Handler for the relevant stream - let (|Stream|) (CategoryId catId) = Handler(handlerLog, resolve catId) - - let render (item: Events.ItemData) : View = - { id = item.id - order = item.order - title = item.title - completed = item.completed } - - (* READ *) + /// Load details for a single specific item + public Task TryGet(ClientId clientId, int id) => + _stream(clientId).Query(s => + { + var i = s.Items.SingleOrDefault(x => x.Id == id); + return i == null ? null : Render(i); + }); - /// List all open items - member __.List(Stream stream) : Async = - stream.Query (fun x -> seq { for x in x.items -> render x }) + // + // WRITE + // - /// Load details for a single specific item - member __.TryGet(Stream stream, id) : Async = - stream.Query (fun x -> x.items |> List.tryFind (fun x -> x.id = id) |> Option.map render) + /// Execute the specified (blind write) command + public Task Execute(ClientId clientId, ICommand command) => + _stream(clientId).Execute(command); - (* WRITE *) + // + // WRITE-READ + // - /// Execute the specified (blind write) command - member __.Execute(Stream stream, command) : Async = - stream.Execute command + /// Create a new ToDo List item; response contains the generated `id` + public async Task Create(ClientId clientId, Props template) + { + var state = await _stream(clientId).Decide(new Commands.Add {Props = template}); + return Render(state.First()); + } - (* WRITE-READ *) + /// Update the specified item as referenced by the `item.id` + public async Task Patch(ClientId clientId, int id, Props value) + { + var state = await _stream(clientId).Decide(new Commands.Update {Id = id, Props = value}); + return Render(state.Single(x => x.Id == id)); + } - /// Create a new ToDo List item; response contains the generated `id` - member __.Create(Stream stream, template: Props) : Async = async { - let! state' = stream.Handle(Commands.Add template) - return List.head state' |> render } + /// Maps a ClientId to the CatId that specifies the Stream in which the data for that client will be held + static Target CategoryId(ClientId id) => + Target.NewCatId("Todos", id?.ToString() ?? "1"); - /// Update the specified item as referenced by the `item.id` - member __.Patch(Stream stream, id: int, value: Props) : Async = async { - let! state' = stream.Handle(Commands.Update (id, value)) - return state' |> List.find (fun x -> x.id = id) |> render} \ No newline at end of file + static View Render(Events.ItemData i) => + new View {Id = i.Id, Order = i.Order, Title = i.Title, Completed = i.Completed}; + } + } +} \ No newline at end of file diff --git a/equinox-web/Domain/TodosService.fs b/equinox-web/Domain/TodosService.fs index 12a34609b..c8a4391f5 100644 --- a/equinox-web/Domain/TodosService.fs +++ b/equinox-web/Domain/TodosService.fs @@ -91,7 +91,7 @@ type View = { id: int; order: int; title: string; completed: bool } /// Defines operations that a Controller can perform on a Todo List type Service(handlerLog, resolve) = - /// Maps a ClientId to the CatId that specifies the Stream in whehch the data for that client will be held + /// Maps a ClientId to the CatId that specifies the Stream in which the data for that client will be held let (|CategoryId|) (clientId: ClientId) = Equinox.CatId("Todos", if obj.ReferenceEquals(clientId,null) then "1" else clientId.Value) /// Maps a ClientId to Handler for the relevant stream From 41691fa2b84932cedb892ed1b3b39e8fdf62f43b Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 4 Jan 2019 17:05:32 +0000 Subject: [PATCH 07/17] Port TodosController --- equinox-web-csharp/Domain/Domain.csproj | 2 +- equinox-web-csharp/Domain/Todo.cs | 6 + .../Web/Controllers/TodosController.cs | 128 ++++++++++-------- equinox-web-csharp/Web/Startup.cs | 37 +++-- equinox-web-csharp/Web/Web.csproj | 2 +- 5 files changed, 96 insertions(+), 79 deletions(-) diff --git a/equinox-web-csharp/Domain/Domain.csproj b/equinox-web-csharp/Domain/Domain.csproj index e95990b8e..96cd68bd9 100755 --- a/equinox-web-csharp/Domain/Domain.csproj +++ b/equinox-web-csharp/Domain/Domain.csproj @@ -6,7 +6,7 @@ - TRACE;DEBUG;todos + TRACE;DEBUG diff --git a/equinox-web-csharp/Domain/Todo.cs b/equinox-web-csharp/Domain/Todo.cs index b70744ccc..dcacd6fcf 100755 --- a/equinox-web-csharp/Domain/Todo.cs +++ b/equinox-web-csharp/Domain/Todo.cs @@ -138,6 +138,12 @@ public static State Fold(State origin, IEnumerable xs) } return new State(nextId, items.ToArray()); } + + /// Determines whether a given event represents a checkpoint that implies we don't need to see any preceding events + public static bool IsOrigin(IEvent e) => e is Events.Cleared || e is Events.Compacted; + + /// Prepares an Event that encodes all relevant aspects of a State such that `evolve` can rehydrate a complete State from it + public static IEvent Compact(State state) => new Events.Compacted { NextId = state.NextId, Items = state.Items }; } /// Properties that can be edited on a Todo List item diff --git a/equinox-web-csharp/Web/Controllers/TodosController.cs b/equinox-web-csharp/Web/Controllers/TodosController.cs index 0f084701c..dc1178254 100755 --- a/equinox-web-csharp/Web/Controllers/TodosController.cs +++ b/equinox-web-csharp/Web/Controllers/TodosController.cs @@ -1,63 +1,79 @@ -namespace TodoBackend.Controllers - -open Microsoft.AspNetCore.Mvc -open TodoBackend - -type FromClientIdHeaderAttribute() = inherit FromHeaderAttribute(Name="COMPLETELY_INSECURE_CLIENT_ID") - -type TodoView = - { id: int - url: string - order: int; title: string; completed: bool } - -type GetByIdArgsTemplate = { id: int } - -// Fulfills contract dictated by https://www.todobackend.com -// To run: -// & dotnet run -f netcoreapp2.1 -p Web -// https://www.todobackend.com/client/index.html?https://localhost:5001/todos -// # NB Jet does now own, control or audit https://todobackend.com; it is a third party site; please satisfy yourself that this is a safe thing use in your environment before using it._ -// See also similar backends used as references when implementing: -// https://github.com/ChristianAlexander/dotnetcore-todo-webapi/blob/master/src/TodoWebApi/Controllers/TodosController.cs -// https://github.com/joeaudette/playground/blob/master/spa-stack/src/FSharp.WebLib/Controllers.fs -[] -type TodosController(service: Todo.Service) = - inherit ControllerBase() - - let toProps (value : TodoView) : Todo.Props = { order = value.order; title = value.title; completed = value.completed } - - member private __.WithUri(x : Todo.View) : TodoView = - let url = __.Url.RouteUrl("GetTodo", { id=x.id }, __.Request.Scheme) // Supplying scheme is secret sauce for making it absolute as required by client - { id = x.id; url = url; order = x.order; title = x.title; completed = x.completed } - - [] - member __.Get([]clientId : ClientId) = async { - let! xs = service.List(clientId) - return seq { for x in xs -> __.WithUri(x) } - } +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; - [] - member __.Get([]clientId : ClientId, id) : Async = async { - let! x = service.TryGet(clientId, id) - return match x with None -> __.NotFound() :> _ | Some x -> ObjectResult(__.WithUri x) :> _ +namespace TodoBackendTemplate.Controllers +{ + public class FromClientIdHeaderAttribute : FromHeaderAttribute + { + public FromClientIdHeaderAttribute() + { + Name = "COMPLETELY_INSECURE_CLIENT_ID"; + } } - [] - member __.Post([]clientId : ClientId, []value : TodoView) : Async = async { - let! created = service.Create(clientId, toProps value) - return __.WithUri created + public class TodoView + { + public int Id { get; set; } + public string Url { get; set; } + public int Order { get; set; } + public string Title { get; set; } + public bool Completed { get; set; } } - [] - member __.Patch([]clientId : ClientId, id, []value : TodoView) : Async = async { - let! updated = service.Patch(clientId, id, toProps value) - return __.WithUri updated - } + // Fulfills contract dictated by https://www.todobackend.com + // To run: + // & dotnet run -f netcoreapp2.1 -p Web + // https://www.todobackend.com/client/index.html?https://localhost:5001/todos + // # NB Jet does now own, control or audit https://todobackend.com; it is a third party site; please satisfy yourself that this is a safe thing use in your environment before using it._ + // See also similar backends used as references when implementing: + // https://github.com/ChristianAlexander/dotnetcore-todo-webapi/blob/master/src/TodoWebApi/Controllers/TodosController.cs + // https://github.com/joeaudette/playground/blob/master/spa-stack/src/FSharp.WebLib/Controllers.fs + [Route("[controller]"), ApiController] + public class TodosController : ControllerBase + { + private readonly Todo.Service _service; + + public TodosController(Todo.Service service) => + _service = service; + + private Todo.Props ToProps(TodoView value) => + new Todo.Props {Order = value.Order, Title = value.Title, Completed = value.Completed}; + + private TodoView WithUri(Todo.View x) + { + // Supplying scheme is secret sauce for making it absolute as required by client + var url = Url.RouteUrl("GetTodo", new {id = x.Id}, Request.Scheme); + return new TodoView {Id = x.Id, Url = url, Order = x.Order, Title = x.Title, Completed = x.Completed}; + } - [] - member __.Delete([]clientId : ClientId, id): Async = - service.Execute(clientId, Todo.Commands.Delete id) + [HttpGet] + public async Task> Get([FromClientIdHeader] ClientId clientId) => + from x in await _service.List(clientId) select WithUri(x); - [] - member __.DeleteAll([]clientId : ClientId): Async = - service.Execute(clientId, Todo.Commands.Clear) \ No newline at end of file + [HttpGet("{id}", Name = "GetTodo")] + public async Task Get([FromClientIdHeader] ClientId clientId, int id) + { + var res = await _service.TryGet(clientId, id); + if (res == null) return NotFound(); + return new ObjectResult(WithUri(res)); + } + + [HttpPost] + public async Task Post([FromClientIdHeader] ClientId clientId, [FromBody] TodoView value) => + WithUri(await _service.Create(clientId, ToProps(value))); + + [HttpPatch("{id}")] + public async Task Patch([FromClientIdHeader] ClientId clientId, int id, [FromBody] TodoView value) => + WithUri(await _service.Patch(clientId, id, ToProps(value))); + + [HttpDelete("{id}")] + public Task Delete([FromClientIdHeader] ClientId clientId, int id) => + _service.Execute(clientId, new Todo.Commands.Delete {Id = id}); + + [HttpDelete] + public Task DeleteAll([FromClientIdHeader] ClientId clientId) => + _service.Execute(clientId, new Todo.Commands.Clear()); + } +} \ No newline at end of file diff --git a/equinox-web-csharp/Web/Startup.cs b/equinox-web-csharp/Web/Startup.cs index 5bf8a5948..fd295c2d8 100755 --- a/equinox-web-csharp/Web/Startup.cs +++ b/equinox-web-csharp/Web/Startup.cs @@ -1,8 +1,3 @@ -#define cosmos -#define eventStore -#define memoryStore -#define todos -#define aggregate using System; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Builder; @@ -49,7 +44,7 @@ private static void ConfigureServices(IServiceCollection services, EquinoxContex }); services.AddSingleton(sp => new ServiceBuilder(context, Serilog.Log.ForContext())); #if todos - services.AddSingleton(sp => sp.GetRequiredService().CreateTodosService()); + services.AddSingleton(sp => sp.GetRequiredService().CreateTodoService()); #endif #if aggregate services.AddSingleton(sp => sp.GetRequiredService().CreateAggregateService()); @@ -120,14 +115,14 @@ public ServiceBuilder(EquinoxContext context, ILogger handlerLog) #if todos public Todo.Service CreateTodoService() => - Todo.Service( + new Todo.Service( _handlerLog, _context.Resolve( - EquinoxCodec.Create(), - Todo.Folds.fold, - Todo.Folds.initial, - Todo.Folds.isOrigin, - Todo.Folds.compact)); + EquinoxCodec.Create(Todo.Events.Encode,Todo.Events.TryDecode), + Todo.Folds.Fold, + Todo.Folds.Initial, + Todo.Folds.IsOrigin, + Todo.Folds.Compact)); #endif #if aggregate public Aggregate.Service CreateAggregateService() => @@ -141,15 +136,15 @@ public Aggregate.Service CreateAggregateService() => Aggregate.Folds.Compact)); #endif #if (!aggregate && !todos) - public Thing.Service CreateAggregateService() => - Aggregate.Service( - _handlerLog, - _context.Resolve( - EquinoxCodec.Create(), - Thing.Folds.fold, - Thing.Folds.initial, - Thing.Folds.isOrigin, - Thing.Folds.compact)); +// public Thing.Service CreateAggregateService() => +// Aggregate.Service( +// _handlerLog, +// _context.Resolve( +// EquinoxCodec.Create(), +// Thing.Folds.fold, +// Thing.Folds.initial, +// Thing.Folds.isOrigin, +// Thing.Folds.compact)); #endif } } diff --git a/equinox-web-csharp/Web/Web.csproj b/equinox-web-csharp/Web/Web.csproj index c831a2700..099c4d5fe 100755 --- a/equinox-web-csharp/Web/Web.csproj +++ b/equinox-web-csharp/Web/Web.csproj @@ -5,7 +5,7 @@ - TRACE;DEBUG;NETCOREAPP;NETCOREAPP2_1;eventStore;cosmos;todos;aggregate + TRACE;memoryStore;todos;aggregate From 5f94a1f943e15976324c722b662d78036df28883 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 4 Jan 2019 22:23:13 +0000 Subject: [PATCH 08/17] Tidy nupkg --- .../.template.config/template.json | 21 +++--- equinox-web-csharp/Domain/Todo.cs | 3 - equinox-web-csharp/Web/CosmosContext.cs | 5 +- equinox-web-csharp/Web/EquinoxContext.cs | 4 +- equinox-web-csharp/Web/EventStoreContext.cs | 4 +- equinox-web-csharp/Web/MemoryStoreContext.cs | 4 +- equinox-web-csharp/Web/Program.cs | 2 +- equinox-web-csharp/Web/Startup.cs | 2 +- equinox-web-csharp/Web/Web.csproj | 2 +- .../equinox-web.sln | 69 ++++++++++--------- equinox-web/.template.config/template.json | 1 + .../Domain/{TodosService.fs => Todo.fs} | 0 equinox-web/Web/Web.fsproj | 4 -- .../Equinox.Templates.fsproj | 7 +- 14 files changed, 60 insertions(+), 68 deletions(-) rename equinox-web-csharp.sln => equinox-web-csharp/equinox-web.sln (81%) rename equinox-web/Domain/{TodosService.fs => Todo.fs} (100%) diff --git a/equinox-web-csharp/.template.config/template.json b/equinox-web-csharp/.template.config/template.json index 717de6d61..b6de45ec0 100755 --- a/equinox-web-csharp/.template.config/template.json +++ b/equinox-web-csharp/.template.config/template.json @@ -2,18 +2,19 @@ "$schema": "http://json.schemastore.org/template", "author": "@jet @bartelink", "classifications": [ - "F#", + "C#", "Web", "Equinox", "Event Sourcing" ], "tags": { - "language": "F#" + "language": "C#" }, - "identity": "Equinox.Template", + "identity": "Equinox.Template.CSharp", + "groupIdentity": "Equinox.Web", "name": "Equinox Web App", "shortName": "equinoxweb", - "sourceName": "TodoBackend", + "sourceName": "TodoBackendTemplate", "preferNameDirectory": true, "symbols": { @@ -48,12 +49,6 @@ "dataType": "bool", "defaultValue": "false", "description": "Store Events in an Azure CosmosDb Account" - }, - "cosmosSimulator": { - "type": "parameter", - "dataType": "bool", - "defaultValue": "false", - "description": "Include code/comments regarding Azure CosmosDb simulator" } }, "sources": [ @@ -63,15 +58,15 @@ "condition": "(!todos)", "exclude": [ "*/Controllers/**/*", - "**/TodosService.fs", - "**/ClientId.fs", + "**/Todo.cs", + "**/ClientId.cs", "README.md" ] }, { "condition": "(!aggregate)", "exclude": [ - "**/Aggregate.fs" + "**/Aggregate.cs" ] } ] diff --git a/equinox-web-csharp/Domain/Todo.cs b/equinox-web-csharp/Domain/Todo.cs index dcacd6fcf..dfc0e28f5 100755 --- a/equinox-web-csharp/Domain/Todo.cs +++ b/equinox-web-csharp/Domain/Todo.cs @@ -5,11 +5,8 @@ using Serilog; using System; using System.Collections.Generic; -using System.ComponentModel; -using System.Data.SqlTypes; using System.Linq; using System.Threading.Tasks; -using Microsoft.FSharp.Collections; namespace TodoBackendTemplate { diff --git a/equinox-web-csharp/Web/CosmosContext.cs b/equinox-web-csharp/Web/CosmosContext.cs index 98b0f92c7..42b807e1b 100644 --- a/equinox-web-csharp/Web/CosmosContext.cs +++ b/equinox-web-csharp/Web/CosmosContext.cs @@ -1,12 +1,11 @@ -using System; -using System.Collections.Generic; using Equinox; using Equinox.Cosmos; -using Equinox.Store; using Equinox.UnionCodec; using Microsoft.FSharp.Control; using Microsoft.FSharp.Core; using Serilog; +using System; +using System.Collections.Generic; namespace TodoBackendTemplate { diff --git a/equinox-web-csharp/Web/EquinoxContext.cs b/equinox-web-csharp/Web/EquinoxContext.cs index 12d7c6b47..892290eb2 100644 --- a/equinox-web-csharp/Web/EquinoxContext.cs +++ b/equinox-web-csharp/Web/EquinoxContext.cs @@ -1,8 +1,8 @@ -using System; -using System.Collections.Generic; using Equinox; using Microsoft.FSharp.Core; using Newtonsoft.Json; +using System; +using System.Collections.Generic; using TypeShape; namespace TodoBackendTemplate diff --git a/equinox-web-csharp/Web/EventStoreContext.cs b/equinox-web-csharp/Web/EventStoreContext.cs index 24b79296c..fea531db8 100644 --- a/equinox-web-csharp/Web/EventStoreContext.cs +++ b/equinox-web-csharp/Web/EventStoreContext.cs @@ -1,11 +1,11 @@ -using System; -using System.Collections.Generic; using Equinox; using Equinox.EventStore; using Equinox.Store; using Equinox.UnionCodec; using Microsoft.FSharp.Control; using Microsoft.FSharp.Core; +using System; +using System.Collections.Generic; namespace TodoBackendTemplate { diff --git a/equinox-web-csharp/Web/MemoryStoreContext.cs b/equinox-web-csharp/Web/MemoryStoreContext.cs index c4f165256..0409ac420 100644 --- a/equinox-web-csharp/Web/MemoryStoreContext.cs +++ b/equinox-web-csharp/Web/MemoryStoreContext.cs @@ -1,10 +1,10 @@ -using System; -using System.Collections.Generic; using Equinox; using Equinox.MemoryStore; using Equinox.Store; using Equinox.UnionCodec; using Microsoft.FSharp.Core; +using System; +using System.Collections.Generic; namespace TodoBackendTemplate { diff --git a/equinox-web-csharp/Web/Program.cs b/equinox-web-csharp/Web/Program.cs index bfbf970b7..224915e22 100755 --- a/equinox-web-csharp/Web/Program.cs +++ b/equinox-web-csharp/Web/Program.cs @@ -1,8 +1,8 @@ -using System; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Serilog; using Serilog.Events; +using System; namespace TodoBackendTemplate.Web { diff --git a/equinox-web-csharp/Web/Startup.cs b/equinox-web-csharp/Web/Startup.cs index fd295c2d8..196055f56 100755 --- a/equinox-web-csharp/Web/Startup.cs +++ b/equinox-web-csharp/Web/Startup.cs @@ -147,4 +147,4 @@ public Aggregate.Service CreateAggregateService() => // Thing.Folds.compact)); #endif } -} +} \ No newline at end of file diff --git a/equinox-web-csharp/Web/Web.csproj b/equinox-web-csharp/Web/Web.csproj index 099c4d5fe..19c932693 100755 --- a/equinox-web-csharp/Web/Web.csproj +++ b/equinox-web-csharp/Web/Web.csproj @@ -5,7 +5,7 @@ - TRACE;memoryStore;todos;aggregate + TRACE;DEBUG;NETCOREAPP;NETCOREAPP2_1 diff --git a/equinox-web-csharp.sln b/equinox-web-csharp/equinox-web.sln similarity index 81% rename from equinox-web-csharp.sln rename to equinox-web-csharp/equinox-web.sln index 6edf300d1..e3cf252c4 100755 --- a/equinox-web-csharp.sln +++ b/equinox-web-csharp/equinox-web.sln @@ -1,33 +1,36 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26124.0 -MinimumVisualStudioVersion = 15.0.26124.0 -Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Web", "equinox-web-csharp\Web\Web.csproj", "{6C72C937-ECFC-4DD4-9BA0-7355B237F974}" -EndProject -Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Domain", "equinox-web-csharp\Domain\Domain.csproj", "{E87F1E85-B2CE-436D-9992-702C068DD338}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{518EE7E2-76AF-4DE9-A127-C2DFF709A468}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {6C72C937-ECFC-4DD4-9BA0-7355B237F974}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6C72C937-ECFC-4DD4-9BA0-7355B237F974}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6C72C937-ECFC-4DD4-9BA0-7355B237F974}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6C72C937-ECFC-4DD4-9BA0-7355B237F974}.Release|Any CPU.Build.0 = Release|Any CPU - {E87F1E85-B2CE-436D-9992-702C068DD338}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E87F1E85-B2CE-436D-9992-702C068DD338}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E87F1E85-B2CE-436D-9992-702C068DD338}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E87F1E85-B2CE-436D-9992-702C068DD338}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {2232D6B6-0CA3-4E51-BFD4-DB0A42EF34BF} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26124.0 +MinimumVisualStudioVersion = 15.0.26124.0 +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Web", "Web\Web.csproj", "{6C72C937-ECFC-4DD4-9BA0-7355B237F974}" +EndProject +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Domain", "Domain\Domain.csproj", "{E87F1E85-B2CE-436D-9992-702C068DD338}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{518EE7E2-76AF-4DE9-A127-C2DFF709A468}" +ProjectSection(SolutionItems) = preProject + README.md = README.md +EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6C72C937-ECFC-4DD4-9BA0-7355B237F974}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C72C937-ECFC-4DD4-9BA0-7355B237F974}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C72C937-ECFC-4DD4-9BA0-7355B237F974}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C72C937-ECFC-4DD4-9BA0-7355B237F974}.Release|Any CPU.Build.0 = Release|Any CPU + {E87F1E85-B2CE-436D-9992-702C068DD338}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E87F1E85-B2CE-436D-9992-702C068DD338}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E87F1E85-B2CE-436D-9992-702C068DD338}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E87F1E85-B2CE-436D-9992-702C068DD338}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {2232D6B6-0CA3-4E51-BFD4-DB0A42EF34BF} + EndGlobalSection +EndGlobal diff --git a/equinox-web/.template.config/template.json b/equinox-web/.template.config/template.json index f28b8664c..a51429b70 100644 --- a/equinox-web/.template.config/template.json +++ b/equinox-web/.template.config/template.json @@ -11,6 +11,7 @@ "language": "F#" }, "identity": "Equinox.Template", + "groupIdentity": "Equinox.Web", "name": "Equinox Web App", "shortName": "equinoxweb", "sourceName": "TodoBackendTemplate", diff --git a/equinox-web/Domain/TodosService.fs b/equinox-web/Domain/Todo.fs similarity index 100% rename from equinox-web/Domain/TodosService.fs rename to equinox-web/Domain/Todo.fs diff --git a/equinox-web/Web/Web.fsproj b/equinox-web/Web/Web.fsproj index 91599607e..c3a6391da 100644 --- a/equinox-web/Web/Web.fsproj +++ b/equinox-web/Web/Web.fsproj @@ -4,10 +4,6 @@ netcoreapp2.1 - - TRACE;todos - - diff --git a/src/Equinox.Templates/Equinox.Templates.fsproj b/src/Equinox.Templates/Equinox.Templates.fsproj index 142565646..f6beedcec 100644 --- a/src/Equinox.Templates/Equinox.Templates.fsproj +++ b/src/Equinox.Templates/Equinox.Templates.fsproj @@ -7,13 +7,14 @@ false Template true - + - - + + + \ No newline at end of file From 19d9b9b0b96d150fa5db8cfdf94434271ff22b74 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 4 Jan 2019 22:47:19 +0000 Subject: [PATCH 09/17] Remove extraneous lang tag --- equinox-web-csharp/.template.config/template.json | 1 - equinox-web/.template.config/template.json | 1 - 2 files changed, 2 deletions(-) diff --git a/equinox-web-csharp/.template.config/template.json b/equinox-web-csharp/.template.config/template.json index b6de45ec0..ba94be52d 100755 --- a/equinox-web-csharp/.template.config/template.json +++ b/equinox-web-csharp/.template.config/template.json @@ -2,7 +2,6 @@ "$schema": "http://json.schemastore.org/template", "author": "@jet @bartelink", "classifications": [ - "C#", "Web", "Equinox", "Event Sourcing" diff --git a/equinox-web/.template.config/template.json b/equinox-web/.template.config/template.json index a51429b70..acd269625 100644 --- a/equinox-web/.template.config/template.json +++ b/equinox-web/.template.config/template.json @@ -2,7 +2,6 @@ "$schema": "http://json.schemastore.org/template", "author": "@jet @bartelink", "classifications": [ - "F#", "Web", "Equinox", "Event Sourcing" From a647cb02696301a78e5f72ecd5075b4535f1bfa1 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 4 Jan 2019 23:34:52 +0000 Subject: [PATCH 10/17] Tidy defaulting --- .../.template.config/template.json | 20 +++++++++++++- equinox-web-csharp/Domain/Domain.csproj | 11 +------- .../Web/Controllers/TodosController.cs | 26 +++++++++---------- equinox-web-csharp/Web/Startup.cs | 13 ++++++---- equinox-web-csharp/Web/Web.csproj | 4 --- equinox-web/.template.config/template.json | 2 +- equinox-web/Web/Startup.fs | 6 ++++- 7 files changed, 46 insertions(+), 36 deletions(-) diff --git a/equinox-web-csharp/.template.config/template.json b/equinox-web-csharp/.template.config/template.json index ba94be52d..c770f782e 100755 --- a/equinox-web-csharp/.template.config/template.json +++ b/equinox-web-csharp/.template.config/template.json @@ -34,7 +34,7 @@ "memoryStore": { "type": "parameter", "dataType": "bool", - "defaultValue": "false", + "defaultValue": "true", "description": "'Store' Events in an In-Memory volatile store (for test purposes only.)" }, "eventStore": { @@ -67,6 +67,24 @@ "exclude": [ "**/Aggregate.cs" ] + }, + { + "condition": "(!memoryStore)", + "exclude": [ + "**/MemoryStoreContext.cs" + ] + }, + { + "condition": "(!eventStore)", + "exclude": [ + "**/EventStoreContext.cs" + ] + }, + { + "condition": "(!cosmos)", + "exclude": [ + "**/CosmosContext.cs" + ] } ] } diff --git a/equinox-web-csharp/Domain/Domain.csproj b/equinox-web-csharp/Domain/Domain.csproj index 96cd68bd9..9ec07ed86 100755 --- a/equinox-web-csharp/Domain/Domain.csproj +++ b/equinox-web-csharp/Domain/Domain.csproj @@ -5,19 +5,10 @@ false - - TRACE;DEBUG - - - - - - - ..\..\..\..\.nuget\packages\newtonsoft.json\11.0.2\lib\netstandard2.0\Newtonsoft.Json.dll - + \ No newline at end of file diff --git a/equinox-web-csharp/Web/Controllers/TodosController.cs b/equinox-web-csharp/Web/Controllers/TodosController.cs index dc1178254..ec859b1f2 100755 --- a/equinox-web-csharp/Web/Controllers/TodosController.cs +++ b/equinox-web-csharp/Web/Controllers/TodosController.cs @@ -7,10 +7,8 @@ namespace TodoBackendTemplate.Controllers { public class FromClientIdHeaderAttribute : FromHeaderAttribute { - public FromClientIdHeaderAttribute() - { + public FromClientIdHeaderAttribute() => Name = "COMPLETELY_INSECURE_CLIENT_ID"; - } } public class TodoView @@ -33,21 +31,11 @@ public class TodoView [Route("[controller]"), ApiController] public class TodosController : ControllerBase { - private readonly Todo.Service _service; + readonly Todo.Service _service; public TodosController(Todo.Service service) => _service = service; - private Todo.Props ToProps(TodoView value) => - new Todo.Props {Order = value.Order, Title = value.Title, Completed = value.Completed}; - - private TodoView WithUri(Todo.View x) - { - // Supplying scheme is secret sauce for making it absolute as required by client - var url = Url.RouteUrl("GetTodo", new {id = x.Id}, Request.Scheme); - return new TodoView {Id = x.Id, Url = url, Order = x.Order, Title = x.Title, Completed = x.Completed}; - } - [HttpGet] public async Task> Get([FromClientIdHeader] ClientId clientId) => from x in await _service.List(clientId) select WithUri(x); @@ -75,5 +63,15 @@ public Task Delete([FromClientIdHeader] ClientId clientId, int id) => [HttpDelete] public Task DeleteAll([FromClientIdHeader] ClientId clientId) => _service.Execute(clientId, new Todo.Commands.Clear()); + + Todo.Props ToProps(TodoView value) => + new Todo.Props {Order = value.Order, Title = value.Title, Completed = value.Completed}; + + TodoView WithUri(Todo.View x) + { + // Supplying scheme is secret sauce for making it absolute as required by client + var url = Url.RouteUrl("GetTodo", new {id = x.Id}, Request.Scheme); + return new TodoView {Id = x.Id, Url = url, Order = x.Order, Title = x.Title, Completed = x.Completed}; + } } } \ No newline at end of file diff --git a/equinox-web-csharp/Web/Startup.cs b/equinox-web-csharp/Web/Startup.cs index 196055f56..537fcadb5 100755 --- a/equinox-web-csharp/Web/Startup.cs +++ b/equinox-web-csharp/Web/Startup.cs @@ -95,8 +95,11 @@ private static EquinoxContext ConfigureStore() var config = new CosmosConfig(connMode, conn, db, coll, cacheMb); return new CosmosContext(config); #endif -#if (memoryStore || (!cosmos && !eventStore)) +#if (memoryStore && !cosmos && !eventStore) return new MemoryStoreContext(new Equinox.MemoryStore.VolatileStore()); +#endif +#if (!memoryStore && !cosmos && !eventStore) + //return new MemoryStoreContext(new Equinox.MemoryStore.VolatileStore()); #endif } } @@ -118,7 +121,7 @@ public Todo.Service CreateTodoService() => new Todo.Service( _handlerLog, _context.Resolve( - EquinoxCodec.Create(Todo.Events.Encode,Todo.Events.TryDecode), + EquinoxCodec.Create(Todo.Events.Encode, Todo.Events.TryDecode), Todo.Folds.Fold, Todo.Folds.Initial, Todo.Folds.IsOrigin, @@ -129,18 +132,18 @@ public Aggregate.Service CreateAggregateService() => new Aggregate.Service( _handlerLog, _context.Resolve( - EquinoxCodec.Create(Aggregate.Events.Encode,Aggregate.Events.TryDecode), + EquinoxCodec.Create(Aggregate.Events.Encode, Aggregate.Events.TryDecode), Aggregate.Folds.Fold, Aggregate.Folds.Initial, Aggregate.Folds.IsOrigin, Aggregate.Folds.Compact)); #endif #if (!aggregate && !todos) -// public Thing.Service CreateAggregateService() => +// public Thing.Service CreateThingService() => // Aggregate.Service( // _handlerLog, // _context.Resolve( -// EquinoxCodec.Create(), +// EquinoxCodec.Create(), // Assumes Union following IUnionContract pattern, see https://eiriktsarpalis.wordpress.com/2018/10/30/a-contract-pattern-for-schemaless-datastores/ // Thing.Folds.fold, // Thing.Folds.initial, // Thing.Folds.isOrigin, diff --git a/equinox-web-csharp/Web/Web.csproj b/equinox-web-csharp/Web/Web.csproj index 19c932693..4114da49f 100755 --- a/equinox-web-csharp/Web/Web.csproj +++ b/equinox-web-csharp/Web/Web.csproj @@ -4,10 +4,6 @@ netcoreapp2.1 - - TRACE;DEBUG;NETCOREAPP;NETCOREAPP2_1 - - diff --git a/equinox-web/.template.config/template.json b/equinox-web/.template.config/template.json index acd269625..e0cbd8da7 100644 --- a/equinox-web/.template.config/template.json +++ b/equinox-web/.template.config/template.json @@ -34,7 +34,7 @@ "memoryStore": { "type": "parameter", "dataType": "bool", - "defaultValue": "false", + "defaultValue": "true", "description": "'Store' Events in an In-Memory volatile store (for test purposes only.)" }, "eventStore": { diff --git a/equinox-web/Web/Startup.fs b/equinox-web/Web/Startup.fs index d842a1244..777acd797 100644 --- a/equinox-web/Web/Startup.fs +++ b/equinox-web/Web/Startup.fs @@ -224,9 +224,13 @@ type Startup() = failwithf "Event Storage subsystem requires the following Environment Variables to be specified: %s, %s, %s" connectionVar databaseVar collectionVar #endif -#if (memoryStore || (!cosmos && !eventStore)) +#if (memoryStore && !cosmos && !eventStore) let storeConfig = Storage.Config.Mem +#endif +#if (!memoryStore && !cosmos && !eventStore) + //let storeConfig = Storage.Config.Mem + #endif Services.register(services, storeConfig) From 882495e2e53a90b0b13f0b81cbcf6be114382d12 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 4 Jan 2019 23:57:14 +0000 Subject: [PATCH 11/17] Final polish --- equinox-web-csharp/Domain/Domain.csproj | 2 +- equinox-web-csharp/Domain/Todo.cs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/equinox-web-csharp/Domain/Domain.csproj b/equinox-web-csharp/Domain/Domain.csproj index 9ec07ed86..f14b1ec35 100755 --- a/equinox-web-csharp/Domain/Domain.csproj +++ b/equinox-web-csharp/Domain/Domain.csproj @@ -8,7 +8,7 @@ - + \ No newline at end of file diff --git a/equinox-web-csharp/Domain/Todo.cs b/equinox-web-csharp/Domain/Todo.cs index dfc0e28f5..5427e2bf0 100755 --- a/equinox-web-csharp/Domain/Todo.cs +++ b/equinox-web-csharp/Domain/Todo.cs @@ -222,10 +222,8 @@ private class Handler { readonly EquinoxHandler _inner; - public Handler(ILogger log, IStream stream) - { + public Handler(ILogger log, IStream stream) => _inner = new EquinoxHandler(Folds.Fold, log, stream); - } /// Execute `command`; does not emit the post state public Task Execute(ICommand c) => From b54e4edeecae8a334ab62b277bbf8a8a50369e10 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Sat, 5 Jan 2019 01:59:40 +0000 Subject: [PATCH 12/17] Fix broken template condition --- equinox-web-csharp/Web/Startup.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/equinox-web-csharp/Web/Startup.cs b/equinox-web-csharp/Web/Startup.cs index 537fcadb5..9c8a17957 100755 --- a/equinox-web-csharp/Web/Startup.cs +++ b/equinox-web-csharp/Web/Startup.cs @@ -48,15 +48,15 @@ private static void ConfigureServices(IServiceCollection services, EquinoxContex #endif #if aggregate services.AddSingleton(sp => sp.GetRequiredService().CreateAggregateService()); -#else +#endif +#if (!aggregate && !todos) //services.Register(fun sp -> sp.Resolve().CreateThingService()) #endif - } private static EquinoxContext ConfigureStore() { -#if cosmos || eventStore +#if (cosmos || eventStore) // This is the allocation limit passed internally to a System.Caching.MemoryCache instance // The primary objects held in the cache are the Folded State of Event-sourced aggregates // see https://docs.microsoft.com/en-us/dotnet/framework/performance/caching-in-net-framework-applications for more information From 4179787fcb05337b418c9a0209315029348efecd Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Sat, 5 Jan 2019 03:42:53 +0000 Subject: [PATCH 13/17] Finalize C# impl --- README.md | 20 +++++++++++-------- .../.template.config/template.json | 3 +-- equinox-web-csharp/Domain/Aggregate.cs | 16 +++++++-------- equinox-web-csharp/Domain/Domain.csproj | 2 +- equinox-web-csharp/Domain/Infrastructure.cs | 10 ++-------- equinox-web-csharp/Domain/Todo.cs | 8 ++++---- equinox-web-csharp/README.md | 6 +++++- equinox-web-csharp/Web/CosmosContext.cs | 20 +++++++------------ equinox-web-csharp/Web/EquinoxContext.cs | 7 ++----- equinox-web-csharp/Web/EventStoreContext.cs | 11 +++------- equinox-web-csharp/Web/MemoryStoreContext.cs | 4 +--- equinox-web-csharp/Web/Web.csproj | 8 ++++---- equinox-web/.template.config/template.json | 3 +-- equinox-web/Domain/Domain.fsproj | 2 +- equinox-web/README.md | 6 +++++- equinox-web/Web/Web.fsproj | 8 ++++---- 16 files changed, 60 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index dc5208ced..f5cf828e4 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # Jet `dotnet new` Templates -This repo hosts the source for Jet's [`dotnet new`](https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-new) templates. While there's presently just one, for [Equinox](https://github.com/jet/equinox), over time the intention is to add templates for other systems where relevant. +This repo hosts the source for Jet's [`dotnet new`](https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-new) templates. While that's presently just for [Equinox](https://github.com/jet/equinox), over time the intention is to add templates for other systems where relevant. ## Available Templates -- [`equinox-web`](equinox-web/readme.md) - Boilerplate for an ASP .NET Core Web App, with an associated storage-independent Backend project. At present, only F# has been implemented. +- [`eqxweb`](equinox-web/readme.md) - Boilerplate for an ASP .NET Core Web App, with an associated storage-independent Domain project. +- [`eqxwebcs`](equinox-web-csharp/readme.md) - Boilerplate for an ASP .NET Core Web App, with an associated storage-independent Domain project _ported to C#_. ## How to use @@ -14,13 +15,16 @@ To use from the command line, the outline is: 1. Install a template locally (use `dotnet new --list` to view your current list) 2. Use `dotnet new` to expand the template in a given directory - # install the templates into `dotnet new`s list of avaiable templates so it can be picked up by + # install the templates into `dotnet new`s list of available templates so it can be picked up by # `dotnet new`, Rider, Visual Studio etc. dotnet new -i Equinox.Templates # --help shows the options including wiring for storage subsystems, - # -t includes an example Controller and Service to test the storage subsystem) - dotnet new equinoxweb -t --help + # -t includes an example Domain, Handler, Service and Controller to test from app to storage subsystem + dotnet new eqxweb -t --help + + # if you want to see a C# equivalent: + dotnet new eqxwebcs -t # see readme.md in the generated code for further instructions regarding the TodoBackend the above -t switch above triggers the inclusion of start readme.md @@ -33,14 +37,14 @@ Please don't hesitate to [create a GitHub issue](https://github.com/jet/dotnet-t See [the Equinox repo's CONTRIBUTING section](https://github.com/jet/equinox/blob/master/README.md#contributing) for general guidelines wrt how contributions are considered specifically wrt Equinox. -The following sorts of things are top of the list for the `equinox-web` template at the present time: +The following sorts of things are top of the list for the `eqxweb*` templates at the present time: - Fixes for typos, adding of info to the readme or comments in the emitted code etc - Small-scale cleanup or clarifications of the emitted code -- support for additional .NET languages in the templates +- support for additional languages in the templates - further straightforward starter projects -While there is no rigid or defined limit to what makes sense to add, it should be borne in mind that `dotnet new equinoxweb` is often going to be a new user's first interaction with Equinox. Hence there's a delicate (and intrinsically subjective) balance to be struck between: +While there is no rigid or defined limit to what makes sense to add, it should be borne in mind that `dotnet new eqxweb` is often going to be a new user's first interaction with Equinox and/or [asp]dotnetcore. Hence there's a delicate (and intrinsically subjective) balance to be struck between: 1. simplicity of programming techniques used / beginner friendliness 2. brevity of the generated code diff --git a/equinox-web-csharp/.template.config/template.json b/equinox-web-csharp/.template.config/template.json index c770f782e..fb4e7662d 100755 --- a/equinox-web-csharp/.template.config/template.json +++ b/equinox-web-csharp/.template.config/template.json @@ -10,9 +10,8 @@ "language": "C#" }, "identity": "Equinox.Template.CSharp", - "groupIdentity": "Equinox.Web", "name": "Equinox Web App", - "shortName": "equinoxweb", + "shortName": "eqxwebcs", "sourceName": "TodoBackendTemplate", "preferNameDirectory": true, diff --git a/equinox-web-csharp/Domain/Aggregate.cs b/equinox-web-csharp/Domain/Aggregate.cs index 0a4d3c82d..0a4053902 100755 --- a/equinox-web-csharp/Domain/Aggregate.cs +++ b/equinox-web-csharp/Domain/Aggregate.cs @@ -113,10 +113,8 @@ private class Handler { readonly EquinoxHandler _inner; - public Handler(ILogger log, IStream stream) - { + public Handler(ILogger log, IStream stream) => _inner = new EquinoxHandler(Folds.Fold, log, stream); - } /// Execute `command`, syncing any events decided upon public Task Execute(ICommand c) => @@ -138,20 +136,20 @@ public class Service /// Maps a ClientId to Handler for the relevant stream readonly Func _stream; + static Target CategoryId(string id) => Target.NewCatId("Aggregate", id); + public Service(ILogger handlerLog, Func> resolve) => _stream = id => new Handler(handlerLog, resolve(CategoryId(id))); - static Target CategoryId(string id) => Target.NewCatId("Aggregate", id); - - static View Render(State s) => new View() {Sorted = s.Happened}; + /// Execute the specified command + public Task Execute(string id, ICommand command) => + _stream(id).Execute(command); /// Read the present state // TOCONSIDER: you should probably be separating this out per CQRS and reading from a denormalized/cached set of projections public Task Read(string id) => _stream(id).Query(Render); - /// Execute the specified command - public Task Execute(string id, ICommand command) => - _stream(id).Execute(command); + static View Render(State s) => new View() {Sorted = s.Happened}; } } } \ No newline at end of file diff --git a/equinox-web-csharp/Domain/Domain.csproj b/equinox-web-csharp/Domain/Domain.csproj index f14b1ec35..ad3ba2e37 100755 --- a/equinox-web-csharp/Domain/Domain.csproj +++ b/equinox-web-csharp/Domain/Domain.csproj @@ -6,7 +6,7 @@ - + diff --git a/equinox-web-csharp/Domain/Infrastructure.cs b/equinox-web-csharp/Domain/Infrastructure.cs index 5b98cc396..47bdca7d4 100644 --- a/equinox-web-csharp/Domain/Infrastructure.cs +++ b/equinox-web-csharp/Domain/Infrastructure.cs @@ -14,8 +14,7 @@ namespace TodoBackendTemplate { public static class HandlerExtensions { - public static void Execute(this Context that, - Func> f) => + public static void Execute(this Context that, Func> f) => that.Execute(FuncConvert.FromFunc>(s => ListModule.OfSeq(f(s)))); } @@ -26,12 +25,7 @@ public EquinoxHandler ILogger log, IStream stream, int maxAttempts = 3) - : base(FuncConvert.FromFunc, TState>(fold), - log, - stream, - maxAttempts, - null, - null) + : base(FuncConvert.FromFunc(fold), log, stream, maxAttempts) { } diff --git a/equinox-web-csharp/Domain/Todo.cs b/equinox-web-csharp/Domain/Todo.cs index 5427e2bf0..24052f0f0 100755 --- a/equinox-web-csharp/Domain/Todo.cs +++ b/equinox-web-csharp/Domain/Todo.cs @@ -258,6 +258,10 @@ public class Service /// Maps a ClientId to Handler for the relevant stream readonly Func _stream; + /// Maps a ClientId to the CatId that specifies the Stream in which the data for that client will be held + static Target CategoryId(ClientId id) => + Target.NewCatId("Todos", id?.ToString() ?? "1"); + public Service(ILogger handlerLog, Func> resolve) => _stream = id => new Handler(handlerLog, resolve(CategoryId(id))); @@ -303,10 +307,6 @@ public async Task Patch(ClientId clientId, int id, Props value) return Render(state.Single(x => x.Id == id)); } - /// Maps a ClientId to the CatId that specifies the Stream in which the data for that client will be held - static Target CategoryId(ClientId id) => - Target.NewCatId("Todos", id?.ToString() ?? "1"); - static View Render(Events.ItemData i) => new View {Id = i.Id, Order = i.Order, Title = i.Title, Completed = i.Completed}; } diff --git a/equinox-web-csharp/README.md b/equinox-web-csharp/README.md index ea69b5d6d..b659d0de9 100644 --- a/equinox-web-csharp/README.md +++ b/equinox-web-csharp/README.md @@ -4,7 +4,11 @@ This project was generated using: dotnet new -i Equinox.Templates # just once, to install in the local templates store - dotnet new equinoxweb -t --language C# # use --help to see options regarding storage subsystem configuration etc + dotnet new eqxwebcs -t # use --help to see options regarding storage subsystem configuration etc + +To generate the F# equivalent: + + dotnet new eqxweb -t # use --help to see options regarding storage subsystem configuration etc To run a local instance of the Website on https://localhost:5001 and http://localhost:5000 diff --git a/equinox-web-csharp/Web/CosmosContext.cs b/equinox-web-csharp/Web/CosmosContext.cs index 42b807e1b..f1077fbfa 100644 --- a/equinox-web-csharp/Web/CosmosContext.cs +++ b/equinox-web-csharp/Web/CosmosContext.cs @@ -36,8 +36,7 @@ public class CosmosContext : EquinoxContext public CosmosContext(CosmosConfig config) { _cache = new Caching.Cache("Cosmos", config.CacheMb); - var retriesOn429Throttling = - 1; // Number of retries before failing processing when provisioned RU/s limit in CosmosDb is breached + var retriesOn429Throttling = 1; // Number of retries before failing processing when provisioned RU/s limit in CosmosDb is breached var timeout = TimeSpan.FromSeconds(5); // Timeout applied per request to CosmosDb, including retry attempts var discovery = Discovery.FromConnectionString(config.ConnectionStringWithUriAndKey); _store = new Lazy(() => @@ -46,7 +45,7 @@ public CosmosContext(CosmosConfig config) (int) timeout.TotalSeconds); var collectionMapping = new EqxCollections(config.Database, config.Collection); - return new EqxStore(gateway, collectionMapping, resolverLog: null); + return new EqxStore(gateway, collectionMapping); }); } @@ -54,15 +53,11 @@ private static EqxGateway Connect(string appName, ConnectionMode mode, Discovery int maxRetryForThrottling, int maxRetryWaitSeconds) { var log = Log.ForContext(); - var c = new EqxConnector(log: log, mode: mode, requestTimeout: - operationTimeout, maxRetryAttemptsOnThrottledRequests: maxRetryForThrottling, - maxRetryWaitTimeInSeconds: - maxRetryWaitSeconds, tags: null, maxConnectionLimit: null, defaultConsistencyLevel: null, - readRetryPolicy: null, writeRetryPolicy: null); + var c = new EqxConnector(log, mode, operationTimeout, + maxRetryAttemptsOnThrottledRequests: maxRetryForThrottling, + maxRetryWaitTimeInSeconds: maxRetryWaitSeconds); var conn = FSharpAsync.RunSynchronously(c.Connect(appName, discovery), null, null); - return new EqxGateway(conn, new EqxBatchingPolicy(defaultMaxItems: 500, getDefaultMaxItems: null, - maxRequests: null, maxEventsPerSlice: null - )); + return new EqxGateway(conn, new EqxBatchingPolicy(defaultMaxItems: 500)); } internal override void Connect() @@ -80,8 +75,7 @@ internal override void Connect() var accessStrategy = isOrigin == null && compact == null ? null - : AccessStrategy.NewSnapshot(FuncConvert.FromFunc(isOrigin), - FuncConvert.FromFunc(compact)); + : AccessStrategy.NewSnapshot(FuncConvert.FromFunc(isOrigin), FuncConvert.FromFunc(compact)); var cacheStrategy = _cache == null ? null diff --git a/equinox-web-csharp/Web/EquinoxContext.cs b/equinox-web-csharp/Web/EquinoxContext.cs index 892290eb2..7c76acc14 100644 --- a/equinox-web-csharp/Web/EquinoxContext.cs +++ b/equinox-web-csharp/Web/EquinoxContext.cs @@ -34,10 +34,7 @@ public static Equinox.UnionCodec.IUnionEncoder Create( FSharpOption TryDecodeImpl(Tuple encoded) => OptionModule.OfObj(tryDecode(encoded.Item1, encoded.Item2)); } - public static Equinox.UnionCodec.IUnionEncoder Create( - JsonSerializerSettings settings = null) where TEvent: UnionContract.IUnionContract - { - return Equinox.UnionCodec.JsonUtf8.Create(settings ?? _defaultSerializationSettings, null, null ); - } + public static Equinox.UnionCodec.IUnionEncoder Create(JsonSerializerSettings settings = null) where TEvent: UnionContract.IUnionContract => + Equinox.UnionCodec.JsonUtf8.Create(settings ?? _defaultSerializationSettings); } } \ No newline at end of file diff --git a/equinox-web-csharp/Web/EventStoreContext.cs b/equinox-web-csharp/Web/EventStoreContext.cs index fea531db8..8e5cc384d 100644 --- a/equinox-web-csharp/Web/EventStoreContext.cs +++ b/equinox-web-csharp/Web/EventStoreContext.cs @@ -40,17 +40,12 @@ public EventStoreContext(EventStoreConfig config) private static GesGateway Connect(EventStoreConfig config) { var log = Logger.NewSerilogNormal(Serilog.Log.ForContext()); - var c = new GesConnector(config.Username, config.Password, reqTimeout: TimeSpan.FromSeconds(5), - reqRetries: 1, - log: log, heartbeatTimeout: null, concurrentOperationsLimit: null, readRetryPolicy: null, - writeRetryPolicy: null, tags: null); + var c = new GesConnector(config.Username, config.Password, reqTimeout: TimeSpan.FromSeconds(5), reqRetries: 1); var conn = FSharpAsync.RunSynchronously( - c.Establish("Twin", Discovery.NewGossipDns(config.Host), - ConnectionStrategy.ClusterTwinPreferSlaveReads), + c.Establish("Twin", Discovery.NewGossipDns(config.Host), ConnectionStrategy.ClusterTwinPreferSlaveReads), null, null); - return new GesGateway(conn, - new GesBatchingPolicy(maxBatchSize: 500)); + return new GesGateway(conn, new GesBatchingPolicy(maxBatchSize: 500)); } internal override void Connect() diff --git a/equinox-web-csharp/Web/MemoryStoreContext.cs b/equinox-web-csharp/Web/MemoryStoreContext.cs index 0409ac420..29887b953 100644 --- a/equinox-web-csharp/Web/MemoryStoreContext.cs +++ b/equinox-web-csharp/Web/MemoryStoreContext.cs @@ -12,10 +12,8 @@ public class MemoryStoreContext : EquinoxContext { readonly VolatileStore _store; - public MemoryStoreContext(VolatileStore store) - { + public MemoryStoreContext(VolatileStore store) => _store = store; - } public override Func> Resolve( IUnionEncoder codec, diff --git a/equinox-web-csharp/Web/Web.csproj b/equinox-web-csharp/Web/Web.csproj index 4114da49f..8a2a987c2 100755 --- a/equinox-web-csharp/Web/Web.csproj +++ b/equinox-web-csharp/Web/Web.csproj @@ -5,10 +5,10 @@ - - - - + + + + diff --git a/equinox-web/.template.config/template.json b/equinox-web/.template.config/template.json index e0cbd8da7..e29058f26 100644 --- a/equinox-web/.template.config/template.json +++ b/equinox-web/.template.config/template.json @@ -10,9 +10,8 @@ "language": "F#" }, "identity": "Equinox.Template", - "groupIdentity": "Equinox.Web", "name": "Equinox Web App", - "shortName": "equinoxweb", + "shortName": "eqxweb", "sourceName": "TodoBackendTemplate", "preferNameDirectory": true, diff --git a/equinox-web/Domain/Domain.fsproj b/equinox-web/Domain/Domain.fsproj index 6c6cc551b..fe88ed63a 100644 --- a/equinox-web/Domain/Domain.fsproj +++ b/equinox-web/Domain/Domain.fsproj @@ -12,7 +12,7 @@ - + diff --git a/equinox-web/README.md b/equinox-web/README.md index 3cfafb765..61aceec5e 100644 --- a/equinox-web/README.md +++ b/equinox-web/README.md @@ -4,7 +4,11 @@ This project was generated using: dotnet new -i Equinox.Templates # just once, to install in the local templates store - dotnet new equinoxweb -t # use --help to see options regarding storage subsystem configuration etc + dotnet new eqxweb -t # use --help to see options regarding storage subsystem configuration etc + +To generate the C# equivalent: + + dotnet new eqxwebcs -t # use --help to see options regarding storage subsystem configuration etc To run a local instance of the Website on https://localhost:5001 and http://localhost:5000 diff --git a/equinox-web/Web/Web.fsproj b/equinox-web/Web/Web.fsproj index c3a6391da..2666e844a 100644 --- a/equinox-web/Web/Web.fsproj +++ b/equinox-web/Web/Web.fsproj @@ -11,10 +11,10 @@ - - - - + + + + From 44be083ba52b29728b902a0d83ce765084eeaeef Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Sat, 5 Jan 2019 04:07:44 +0000 Subject: [PATCH 14/17] Final fixes --- equinox-web-csharp/Web/CosmosContext.cs | 4 +--- equinox-web-csharp/Web/Startup.cs | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/equinox-web-csharp/Web/CosmosContext.cs b/equinox-web-csharp/Web/CosmosContext.cs index f1077fbfa..523c6f576 100644 --- a/equinox-web-csharp/Web/CosmosContext.cs +++ b/equinox-web-csharp/Web/CosmosContext.cs @@ -53,9 +53,7 @@ private static EqxGateway Connect(string appName, ConnectionMode mode, Discovery int maxRetryForThrottling, int maxRetryWaitSeconds) { var log = Log.ForContext(); - var c = new EqxConnector(log, mode, operationTimeout, - maxRetryAttemptsOnThrottledRequests: maxRetryForThrottling, - maxRetryWaitTimeInSeconds: maxRetryWaitSeconds); + var c = new EqxConnector(operationTimeout, maxRetryForThrottling, maxRetryWaitSeconds, log, mode: mode); var conn = FSharpAsync.RunSynchronously(c.Connect(appName, discovery), null, null); return new EqxGateway(conn, new EqxBatchingPolicy(defaultMaxItems: 500)); } diff --git a/equinox-web-csharp/Web/Startup.cs b/equinox-web-csharp/Web/Startup.cs index 9c8a17957..6304b0807 100755 --- a/equinox-web-csharp/Web/Startup.cs +++ b/equinox-web-csharp/Web/Startup.cs @@ -95,7 +95,7 @@ private static EquinoxContext ConfigureStore() var config = new CosmosConfig(connMode, conn, db, coll, cacheMb); return new CosmosContext(config); #endif -#if (memoryStore && !cosmos && !eventStore) +#if (!cosmos && !eventStore) return new MemoryStoreContext(new Equinox.MemoryStore.VolatileStore()); #endif #if (!memoryStore && !cosmos && !eventStore) From dc1446d828c98baea9846174c7692752a85986bc Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Tue, 8 Jan 2019 10:43:53 +0000 Subject: [PATCH 15/17] Tidy ClientId --- equinox-web-csharp/Domain/ClientId.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/equinox-web-csharp/Domain/ClientId.cs b/equinox-web-csharp/Domain/ClientId.cs index bdeaac81e..f584dc7ab 100755 --- a/equinox-web-csharp/Domain/ClientId.cs +++ b/equinox-web-csharp/Domain/ClientId.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel; using System.Globalization; +using System.Runtime.Serialization; namespace TodoBackendTemplate { @@ -11,9 +12,9 @@ public class ClientId { private ClientId(Guid value) => Value = value; + [IgnoreDataMember] // Prevent Swashbuckle inferring there is a Value property public Guid Value { get; } - // NB for validation [and XSS] purposes we must prove it translatable to a Guid public static ClientId Parse(string input) => new ClientId(Guid.Parse(input)); public override string ToString() => Value.ToString("N"); From 8af6c1ceb575aaa15ee30e19d7c1b7842741fa7e Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Tue, 8 Jan 2019 11:49:19 +0000 Subject: [PATCH 16/17] Tidy C# a lot --- equinox-web-csharp/Domain/Aggregate.cs | 85 +++++------ equinox-web-csharp/Domain/ClientId.cs | 8 +- equinox-web-csharp/Domain/Todo.cs | 135 ++++++++---------- .../Web/Controllers/TodosController.cs | 4 +- equinox-web-csharp/Web/CosmosContext.cs | 34 ++--- equinox-web-csharp/Web/EquinoxContext.cs | 3 +- equinox-web-csharp/Web/EventStoreContext.cs | 21 ++- equinox-web-csharp/Web/MemoryStoreContext.cs | 5 +- equinox-web-csharp/Web/Program.cs | 13 +- equinox-web-csharp/Web/Startup.cs | 32 ++--- 10 files changed, 152 insertions(+), 188 deletions(-) diff --git a/equinox-web-csharp/Domain/Aggregate.cs b/equinox-web-csharp/Domain/Aggregate.cs index 0a4053902..fbd527bc8 100755 --- a/equinox-web-csharp/Domain/Aggregate.cs +++ b/equinox-web-csharp/Domain/Aggregate.cs @@ -9,27 +9,23 @@ namespace TodoBackendTemplate { - public class Aggregate + public static class Aggregate { - public interface IEvent - { - } - /// NB - these types and names reflect the actual storage formats and hence need to be versioned with care - public static class Events + public abstract class Event { - public class Happened : IEvent + public class Happened : Event { } - public class Compacted : IEvent + public class Compacted : Event { - public bool Happened { get; set; } + public new bool Happened { get; set; } } - private static readonly JsonNetUtf8Codec Codec = new JsonNetUtf8Codec(new JsonSerializerSettings()); + static readonly JsonNetUtf8Codec Codec = new JsonNetUtf8Codec(new JsonSerializerSettings()); - public static IEvent TryDecode(string et, byte[] json) + public static Event TryDecode(string et, byte[] json) { switch (et) { @@ -39,87 +35,74 @@ public static IEvent TryDecode(string et, byte[] json) } } - public static Tuple Encode(IEvent x) - { - switch (x) - { - case Happened e: return Tuple.Create(nameof(Happened), Codec.Encode(e)); - case Compacted e: return Tuple.Create(nameof(Compacted), Codec.Encode(e)); - default: return null; - } - } + public static (string, byte[]) Encode(Event e) => (e.GetType().Name, Codec.Encode(e)); } - public class State { public bool Happened { get; set; } - } - public static class Folds - { - public static readonly State Initial = new State {Happened = false}; + internal State(bool happened) { Happened = happened; } + + public static readonly State Initial = new State(false); - private static void Evolve(State s, IEvent x) + static void Evolve(State s, Event x) { switch (x) { - case Events.Happened e: + case Event.Happened e: s.Happened = true; break; - case Events.Compacted e: + case Event.Compacted e: s.Happened = e.Happened; break; default: throw new ArgumentOutOfRangeException(nameof(x), x, "invalid"); } } - public static State Fold(State origin, IEnumerable xs) + public static State Fold(State origin, IEnumerable xs) { - var s = new State {Happened = origin.Happened}; - foreach (var x in xs) Evolve(s, x); + // NB Fold must not mutate the origin + var s = new State(origin.Happened); + foreach (var x in xs) + Evolve(s, x); return s; } - public static bool IsOrigin(IEvent e) => e is Events.Compacted; - - public static IEvent Compact(State s) => new Events.Compacted {Happened = s.Happened}; - } - - public interface ICommand - { + public static bool IsOrigin(Event e) => e is Event.Compacted; + + public static Event Compact(State s) => new Event.Compacted {Happened = s.Happened}; } /// Defines the decision process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream - public static class Commands + public abstract class Command { - public class MakeItSo : ICommand + public class MakeItSo : Command { } - public static IEnumerable Interpret(State s, ICommand x) + public static IEnumerable Interpret(State s, Command x) { switch (x) { case MakeItSo c: - if (!s.Happened) yield return new Events.Happened(); + if (!s.Happened) yield return new Event.Happened(); break; default: throw new ArgumentOutOfRangeException(nameof(x), x, "invalid"); } } } - - private class Handler + class Handler { - readonly EquinoxHandler _inner; + readonly EquinoxHandler _inner; - public Handler(ILogger log, IStream stream) => - _inner = new EquinoxHandler(Folds.Fold, log, stream); + public Handler(ILogger log, IStream stream) => + _inner = new EquinoxHandler(State.Fold, log, stream); /// Execute `command`, syncing any events decided upon - public Task Execute(ICommand c) => + public Task Execute(Command c) => _inner.Execute(ctx => - ctx.Execute(s => Commands.Interpret(s, c))); + ctx.Execute(s => Command.Interpret(s, c))); /// Establish the present state of the Stream, project from that as specified by `projection` public Task Query(Func projection) => @@ -138,11 +121,11 @@ public class Service static Target CategoryId(string id) => Target.NewCatId("Aggregate", id); - public Service(ILogger handlerLog, Func> resolve) => + public Service(ILogger handlerLog, Func> resolve) => _stream = id => new Handler(handlerLog, resolve(CategoryId(id))); /// Execute the specified command - public Task Execute(string id, ICommand command) => + public Task Execute(string id, Command command) => _stream(id).Execute(command); /// Read the present state diff --git a/equinox-web-csharp/Domain/ClientId.cs b/equinox-web-csharp/Domain/ClientId.cs index f584dc7ab..a8ab98a01 100755 --- a/equinox-web-csharp/Domain/ClientId.cs +++ b/equinox-web-csharp/Domain/ClientId.cs @@ -15,6 +15,7 @@ public class ClientId [IgnoreDataMember] // Prevent Swashbuckle inferring there is a Value property public Guid Value { get; } + // TOCONSIDER - happy for this to become a ctor and ClientIdStringConverter to be removed if it just works correctly as-is when a header is supplied public static ClientId Parse(string input) => new ClientId(Guid.Parse(input)); public override string ToString() => Value.ToString("N"); @@ -28,10 +29,7 @@ public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceT public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) => value is string s ? ClientId.Parse(s) : base.ConvertFrom(context, culture, value); - public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, - Type destinationType) => - value is ClientId c && destinationType == typeof(string) - ? c.Value - : base.ConvertTo(context, culture, value, destinationType); + public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType) => + base.ConvertTo(context, culture, value, destinationType); } } \ No newline at end of file diff --git a/equinox-web-csharp/Domain/Todo.cs b/equinox-web-csharp/Domain/Todo.cs index 24052f0f0..7e12ecf53 100755 --- a/equinox-web-csharp/Domain/Todo.cs +++ b/equinox-web-csharp/Domain/Todo.cs @@ -10,14 +10,10 @@ namespace TodoBackendTemplate { - public class Todo + public static class Todo { - public interface IEvent - { - } - /// NB - these types and names reflect the actual storage formats and hence need to be versioned with care - public static class Events + public abstract class Event { /// Information we retain per Todo List entry public abstract class ItemData @@ -28,33 +24,38 @@ public abstract class ItemData public bool Completed { get; set; } } - public class Added : ItemData, IEvent + public abstract class ItemEvent : Event { + public ItemData Data { get; set; } } - public class Updated : ItemData, IEvent + public class Added : ItemEvent { } - public class Deleted : IEvent + public class Updated : ItemEvent + { + } + + public class Deleted : Event { public int Id { get; set; } } - public class Cleared : IEvent + public class Cleared : Event { public int NextId { get; set; } } - public class Compacted : IEvent + public class Compacted : Event { public int NextId { get; set; } public ItemData[] Items { get; set; } } - private static readonly JsonNetUtf8Codec Codec = new JsonNetUtf8Codec(new JsonSerializerSettings()); + static readonly JsonNetUtf8Codec Codec = new JsonNetUtf8Codec(new JsonSerializerSettings()); - public static IEvent TryDecode(string et, byte[] json) + public static Event TryDecode(string et, byte[] json) { switch (et) { @@ -67,18 +68,7 @@ public static IEvent TryDecode(string et, byte[] json) } } - public static Tuple Encode(IEvent x) - { - switch (x) - { - case Added e: return Tuple.Create(nameof(Added), Codec.Encode(e)); - case Updated e: return Tuple.Create(nameof(Updated), Codec.Encode(e)); - case Deleted e: return Tuple.Create(nameof(Deleted), Codec.Encode(e)); - case Cleared e: return Tuple.Create(nameof(Cleared), Codec.Encode(e)); - case Compacted e: return Tuple.Create(nameof(Compacted), Codec.Encode(e)); - default: return null; - } - } + public static (string, byte[]) Encode(Event e) => (e.GetType().Name, Codec.Encode(e)); } /// Present state of the Todo List as inferred from the Events we've seen to date @@ -89,44 +79,41 @@ public static Tuple Encode(IEvent x) public class State { public int NextId { get; } - public Events.ItemData[] Items { get; } + public Event.ItemData[] Items { get; } - public State(int nextId, Events.ItemData[] items) + internal State(int nextId, Event.ItemData[] items) { NextId = nextId; Items = items; } - } - public static class Folds - { - public static State Initial = new State(0, new Events.ItemData[0]); + public static State Initial = new State(0, new Event.ItemData[0]); /// Folds a set of events from the store into a given `state` - public static State Fold(State origin, IEnumerable xs) + public static State Fold(State origin, IEnumerable xs) { var nextId = origin.NextId; var items = origin.Items.ToList(); foreach (var x in xs) switch (x) { - case Events.Added e: + case Event.Added e: nextId++; - items.Insert(0, e); + items.Insert(0, e.Data); break; - case Events.Updated e: - var i = items.FindIndex(item => item.Id == e.Id); + case Event.Updated e: + var i = items.FindIndex(item => item.Id == e.Data.Id); if (i != -1) - items[i] = e; + items[i] = e.Data; break; - case Events.Deleted e: + case Event.Deleted e: items.RemoveAll(item => item.Id == e.Id); break; - case Events.Cleared e: + case Event.Cleared e: nextId = e.NextId; items.Clear(); break; - case Events.Compacted e: + case Event.Compacted e: nextId = e.NextId; items = e.Items.ToList(); break; @@ -137,10 +124,10 @@ public static State Fold(State origin, IEnumerable xs) } /// Determines whether a given event represents a checkpoint that implies we don't need to see any preceding events - public static bool IsOrigin(IEvent e) => e is Events.Cleared || e is Events.Compacted; + public static bool IsOrigin(Event e) => e is Event.Cleared || e is Event.Compacted; /// Prepares an Event that encodes all relevant aspects of a State such that `evolve` can rehydrate a complete State from it - public static IEvent Compact(State state) => new Events.Compacted { NextId = state.NextId, Items = state.Items }; + public static Event Compact(State state) => new Event.Compacted { NextId = state.NextId, Items = state.Items }; } /// Properties that can be edited on a Todo List item @@ -152,89 +139,85 @@ public class Props } /// Defines the operations a caller can perform on a Todo List - public interface ICommand - { - } - - /// Defines the decision process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream - public static class Commands + public abstract class Command { /// Create a single item - public class Add : ICommand + public class Add : Command { public Props Props { get; set; } } /// Update a single item - public class Update : ICommand + public class Update : Command { public int Id { get; set; } public Props Props { get; set; } } /// Delete a single item from the list - public class Delete : ICommand + public class Delete : Command { public int Id { get; set; } } /// Complete clear the todo list - public class Clear : ICommand + public class Clear : Command { } - public static IEnumerable Interpret(State s, ICommand x) + /// Defines the decision process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream + public static IEnumerable Interpret(State s, Command x) { switch (x) { case Add c: - yield return Make(s.NextId, c.Props); + yield return Make(s.NextId, c.Props); break; case Update c: - var proposed = Tuple.Create(c.Props.Order, c.Props.Title, c.Props.Completed); + var proposed = new {c.Props.Order, c.Props.Title, c.Props.Completed}; - bool IsEquivalent(Events.ItemData i) => + bool IsEquivalent(Event.ItemData i) => i.Id == c.Id - && Tuple.Create(i.Order, i.Title, i.Completed).Equals(proposed); + && new {i.Order, i.Title, i.Completed} == proposed; if (!s.Items.Any(IsEquivalent)) - yield return Make(c.Id, c.Props); + yield return Make(c.Id, c.Props); break; case Delete c: if (s.Items.Any(i => i.Id == c.Id)) - yield return new Events.Deleted {Id = c.Id}; + yield return new Event.Deleted {Id = c.Id}; break; - case Clear c: - if (s.Items.Any()) yield return new Events.Cleared {NextId = s.NextId}; + case Clear _: + if (s.Items.Any()) yield return new Event.Cleared {NextId = s.NextId}; break; default: throw new ArgumentOutOfRangeException(nameof(x), x, "invalid"); } - T Make(int id, Props value) where T : Events.ItemData, IEvent, new() => - new T() {Id = id, Order = value.Order, Title = value.Title, Completed = value.Completed}; + T Make(int id, Props value) where T : Event.ItemEvent, new() => + new T {Data = {Id = id, Order = value.Order, Title = value.Title, Completed = value.Completed}}; } } /// Defines low level stream operations relevant to the Todo Stream in terms of Command and Events - private class Handler + class Handler { - readonly EquinoxHandler _inner; + readonly EquinoxHandler _inner; - public Handler(ILogger log, IStream stream) => - _inner = new EquinoxHandler(Folds.Fold, log, stream); + public Handler(ILogger log, IStream stream) => + _inner = new EquinoxHandler(State.Fold, log, stream); /// Execute `command`; does not emit the post state - public Task Execute(ICommand c) => + public Task Execute(Command c) => _inner.Execute(ctx => - ctx.Execute(s => Commands.Interpret(s, c))); + ctx.Execute(s => Command.Interpret(s, c))); /// Handle `command`, return the items after the command's intent has been applied to the stream - public Task Decide(ICommand c) => + public Task Decide(Command c) => _inner.Decide(ctx => { - ctx.Execute(s => Commands.Interpret(s, c)); + ctx.Execute(s => Command.Interpret(s, c)); return ctx.State.Items; }); @@ -262,7 +245,7 @@ public class Service static Target CategoryId(ClientId id) => Target.NewCatId("Todos", id?.ToString() ?? "1"); - public Service(ILogger handlerLog, Func> resolve) => + public Service(ILogger handlerLog, Func> resolve) => _stream = id => new Handler(handlerLog, resolve(CategoryId(id))); // @@ -286,7 +269,7 @@ public Task TryGet(ClientId clientId, int id) => // /// Execute the specified (blind write) command - public Task Execute(ClientId clientId, ICommand command) => + public Task Execute(ClientId clientId, Command command) => _stream(clientId).Execute(command); // @@ -296,18 +279,18 @@ public Task Execute(ClientId clientId, ICommand command) => /// Create a new ToDo List item; response contains the generated `id` public async Task Create(ClientId clientId, Props template) { - var state = await _stream(clientId).Decide(new Commands.Add {Props = template}); + var state = await _stream(clientId).Decide(new Command.Add {Props = template}); return Render(state.First()); } /// Update the specified item as referenced by the `item.id` public async Task Patch(ClientId clientId, int id, Props value) { - var state = await _stream(clientId).Decide(new Commands.Update {Id = id, Props = value}); + var state = await _stream(clientId).Decide(new Command.Update {Id = id, Props = value}); return Render(state.Single(x => x.Id == id)); } - static View Render(Events.ItemData i) => + static View Render(Event.ItemData i) => new View {Id = i.Id, Order = i.Order, Title = i.Title, Completed = i.Completed}; } } diff --git a/equinox-web-csharp/Web/Controllers/TodosController.cs b/equinox-web-csharp/Web/Controllers/TodosController.cs index ec859b1f2..24e1297bf 100755 --- a/equinox-web-csharp/Web/Controllers/TodosController.cs +++ b/equinox-web-csharp/Web/Controllers/TodosController.cs @@ -58,11 +58,11 @@ public async Task Patch([FromClientIdHeader] ClientId clientId, int id [HttpDelete("{id}")] public Task Delete([FromClientIdHeader] ClientId clientId, int id) => - _service.Execute(clientId, new Todo.Commands.Delete {Id = id}); + _service.Execute(clientId, new Todo.Command.Delete {Id = id}); [HttpDelete] public Task DeleteAll([FromClientIdHeader] ClientId clientId) => - _service.Execute(clientId, new Todo.Commands.Clear()); + _service.Execute(clientId, new Todo.Command.Clear()); Todo.Props ToProps(TodoView value) => new Todo.Props {Order = value.Order, Title = value.Title, Completed = value.Completed}; diff --git a/equinox-web-csharp/Web/CosmosContext.cs b/equinox-web-csharp/Web/CosmosContext.cs index 523c6f576..02b2817ac 100644 --- a/equinox-web-csharp/Web/CosmosContext.cs +++ b/equinox-web-csharp/Web/CosmosContext.cs @@ -6,6 +6,7 @@ using Serilog; using System; using System.Collections.Generic; +using System.Threading.Tasks; namespace TodoBackendTemplate { @@ -30,39 +31,38 @@ public CosmosConfig(ConnectionMode mode, string connectionStringWithUriAndKey, s public class CosmosContext : EquinoxContext { - readonly Lazy _store; readonly Caching.Cache _cache; + EqxStore _store; + readonly Func _connect; + public CosmosContext(CosmosConfig config) { _cache = new Caching.Cache("Cosmos", config.CacheMb); var retriesOn429Throttling = 1; // Number of retries before failing processing when provisioned RU/s limit in CosmosDb is breached var timeout = TimeSpan.FromSeconds(5); // Timeout applied per request to CosmosDb, including retry attempts var discovery = Discovery.FromConnectionString(config.ConnectionStringWithUriAndKey); - _store = new Lazy(() => - { - var gateway = Connect("App", config.Mode, discovery, timeout, retriesOn429Throttling, - (int) timeout.TotalSeconds); - var collectionMapping = new EqxCollections(config.Database, config.Collection); + _connect = async () => + { + var gateway = await Connect("App", config.Mode, discovery, timeout, retriesOn429Throttling, + (int)timeout.TotalSeconds); + var collectionMapping = new EqxCollections(config.Database, config.Collection); - return new EqxStore(gateway, collectionMapping); - }); - } + _store = new EqxStore(gateway, collectionMapping); + }; + } + + internal override async Task Connect() => await _connect(); - private static EqxGateway Connect(string appName, ConnectionMode mode, Discovery discovery, TimeSpan operationTimeout, + static async Task Connect(string appName, ConnectionMode mode, Discovery discovery, TimeSpan operationTimeout, int maxRetryForThrottling, int maxRetryWaitSeconds) { var log = Log.ForContext(); var c = new EqxConnector(operationTimeout, maxRetryForThrottling, maxRetryWaitSeconds, log, mode: mode); - var conn = FSharpAsync.RunSynchronously(c.Connect(appName, discovery), null, null); + var conn = await FSharpAsync.StartAsTask(c.Connect(appName, discovery), null, null); return new EqxGateway(conn, new EqxBatchingPolicy(defaultMaxItems: 500)); } - internal override void Connect() - { - var _ = _store.Value; - } - public override Func> Resolve( IUnionEncoder codec, Func, TState> fold, @@ -78,7 +78,7 @@ internal override void Connect() var cacheStrategy = _cache == null ? null : CachingStrategy.NewSlidingWindow(_cache, TimeSpan.FromMinutes(20)); - var resolver = new EqxResolver(_store.Value, codec, FuncConvert.FromFunc(fold), initial, accessStrategy, cacheStrategy); + var resolver = new EqxResolver(_store, codec, FuncConvert.FromFunc(fold), initial, accessStrategy, cacheStrategy); return t => resolver.Resolve.Invoke(t); } } diff --git a/equinox-web-csharp/Web/EquinoxContext.cs b/equinox-web-csharp/Web/EquinoxContext.cs index 7c76acc14..ca49077da 100644 --- a/equinox-web-csharp/Web/EquinoxContext.cs +++ b/equinox-web-csharp/Web/EquinoxContext.cs @@ -3,6 +3,7 @@ using Newtonsoft.Json; using System; using System.Collections.Generic; +using System.Threading.Tasks; using TypeShape; namespace TodoBackendTemplate @@ -16,7 +17,7 @@ public abstract class EquinoxContext Func isOrigin = null, Func compact = null); - internal abstract void Connect(); + internal abstract Task Connect(); } public static class EquinoxCodec diff --git a/equinox-web-csharp/Web/EventStoreContext.cs b/equinox-web-csharp/Web/EventStoreContext.cs index 8e5cc384d..1c8cc4b22 100644 --- a/equinox-web-csharp/Web/EventStoreContext.cs +++ b/equinox-web-csharp/Web/EventStoreContext.cs @@ -6,6 +6,7 @@ using Microsoft.FSharp.Core; using System; using System.Collections.Generic; +using System.Threading.Tasks; namespace TodoBackendTemplate { @@ -27,32 +28,30 @@ public EventStoreConfig(string host, string username, string password, int cache public class EventStoreContext : EquinoxContext { - readonly Lazy _gateway; readonly Caching.Cache _cache; + GesGateway _gateway; + readonly Func _connect; + public EventStoreContext(EventStoreConfig config) { - _cache = new Caching.Cache("Es", config.CacheMb); - _gateway = new Lazy(() => Connect(config)); + _connect = async () => _gateway = await Connect(config); } - private static GesGateway Connect(EventStoreConfig config) + internal override async Task Connect() => await _connect(); + + static async Task Connect(EventStoreConfig config) { var log = Logger.NewSerilogNormal(Serilog.Log.ForContext()); var c = new GesConnector(config.Username, config.Password, reqTimeout: TimeSpan.FromSeconds(5), reqRetries: 1); - var conn = FSharpAsync.RunSynchronously( + var conn = await FSharpAsync.StartAsTask( c.Establish("Twin", Discovery.NewGossipDns(config.Host), ConnectionStrategy.ClusterTwinPreferSlaveReads), null, null); return new GesGateway(conn, new GesBatchingPolicy(maxBatchSize: 500)); } - internal override void Connect() - { - var _ = _gateway.Value; - } - public override Func> Resolve( IUnionEncoder codec, Func, TState> fold, @@ -67,7 +66,7 @@ public override Func> Resolve( var cacheStrategy = _cache == null ? null : CachingStrategy.NewSlidingWindow(_cache, TimeSpan.FromMinutes(20)); - var resolver = new GesResolver(_gateway.Value, codec, FuncConvert.FromFunc(fold), + var resolver = new GesResolver(_gateway, codec, FuncConvert.FromFunc(fold), initial, accessStrategy, cacheStrategy); return t => resolver.Resolve.Invoke(t); } diff --git a/equinox-web-csharp/Web/MemoryStoreContext.cs b/equinox-web-csharp/Web/MemoryStoreContext.cs index 29887b953..018a5e0c4 100644 --- a/equinox-web-csharp/Web/MemoryStoreContext.cs +++ b/equinox-web-csharp/Web/MemoryStoreContext.cs @@ -5,6 +5,7 @@ using Microsoft.FSharp.Core; using System; using System.Collections.Generic; +using System.Threading.Tasks; namespace TodoBackendTemplate { @@ -26,8 +27,6 @@ public override Func> Resolve( return target => resolver.Resolve.Invoke(target); } - internal override void Connect() - { - } + internal override Task Connect() => Task.CompletedTask; } } \ No newline at end of file diff --git a/equinox-web-csharp/Web/Program.cs b/equinox-web-csharp/Web/Program.cs index 224915e22..cdf591dd3 100755 --- a/equinox-web-csharp/Web/Program.cs +++ b/equinox-web-csharp/Web/Program.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; using Serilog; using Serilog.Events; using System; @@ -8,7 +9,7 @@ namespace TodoBackendTemplate.Web { static class Program { - public static int Main(string[] argv) + public static async int Main(string[] argv) { try { @@ -18,11 +19,15 @@ public static int Main(string[] argv) .Enrich.FromLogContext() .WriteTo.Console() .CreateLogger(); - WebHost + var host = WebHost .CreateDefaultBuilder(argv) .UseStartup() - .Build() - .Run(); + .Build(); + // Conceptually, these can run in parallel + // in practice, you'll only very rarely have >1 store + foreach (var ctx in host.Services.GetServices()) + await ctx.Connect(); + host.Run(); return 0; } catch (Exception e) diff --git a/equinox-web-csharp/Web/Startup.cs b/equinox-web-csharp/Web/Startup.cs index 6304b0807..809149d77 100755 --- a/equinox-web-csharp/Web/Startup.cs +++ b/equinox-web-csharp/Web/Startup.cs @@ -35,13 +35,9 @@ public void ConfigureServices(IServiceCollection services) ConfigureServices(services, equinoxContext); } - private static void ConfigureServices(IServiceCollection services, EquinoxContext context) + static void ConfigureServices(IServiceCollection services, EquinoxContext context) { - services.AddSingleton(_ => - { - context.Connect(); - return context; - }); + services.AddSingleton(_ => context); services.AddSingleton(sp => new ServiceBuilder(context, Serilog.Log.ForContext())); #if todos services.AddSingleton(sp => sp.GetRequiredService().CreateTodoService()); @@ -54,7 +50,7 @@ private static void ConfigureServices(IServiceCollection services, EquinoxContex #endif } - private static EquinoxContext ConfigureStore() + static EquinoxContext ConfigureStore() { #if (cosmos || eventStore) // This is the allocation limit passed internally to a System.Caching.MemoryCache instance @@ -121,26 +117,26 @@ public Todo.Service CreateTodoService() => new Todo.Service( _handlerLog, _context.Resolve( - EquinoxCodec.Create(Todo.Events.Encode, Todo.Events.TryDecode), - Todo.Folds.Fold, - Todo.Folds.Initial, - Todo.Folds.IsOrigin, - Todo.Folds.Compact)); + EquinoxCodec.Create(Todo.Event.Encode, Todo.Event.TryDecode), + Todo.State.Fold, + Todo.State.Initial, + Todo.State.IsOrigin, + Todo.State.Compact)); #endif #if aggregate public Aggregate.Service CreateAggregateService() => new Aggregate.Service( _handlerLog, _context.Resolve( - EquinoxCodec.Create(Aggregate.Events.Encode, Aggregate.Events.TryDecode), - Aggregate.Folds.Fold, - Aggregate.Folds.Initial, - Aggregate.Folds.IsOrigin, - Aggregate.Folds.Compact)); + EquinoxCodec.Create(Aggregate.Event.Encode, Aggregate.Event.TryDecode), + Aggregate.State.Fold, + Aggregate.State.Initial, + Aggregate.State.IsOrigin, + Aggregate.State.Compact)); #endif #if (!aggregate && !todos) // public Thing.Service CreateThingService() => -// Aggregate.Service( +// Thing.Service( // _handlerLog, // _context.Resolve( // EquinoxCodec.Create(), // Assumes Union following IUnionContract pattern, see https://eiriktsarpalis.wordpress.com/2018/10/30/a-contract-pattern-for-schemaless-datastores/ From 1c25adee7e0cace6aef1bb9174dc76f5c07c5f28 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Thu, 17 Jan 2019 15:40:34 +0000 Subject: [PATCH 17/17] Target 1.0.3 non-preview --- equinox-web-csharp/Domain/Domain.csproj | 2 +- equinox-web-csharp/Web/Web.csproj | 8 ++++---- equinox-web/Domain/Domain.fsproj | 2 +- equinox-web/Web/Web.fsproj | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/equinox-web-csharp/Domain/Domain.csproj b/equinox-web-csharp/Domain/Domain.csproj index ad3ba2e37..985921276 100755 --- a/equinox-web-csharp/Domain/Domain.csproj +++ b/equinox-web-csharp/Domain/Domain.csproj @@ -6,7 +6,7 @@ - + diff --git a/equinox-web-csharp/Web/Web.csproj b/equinox-web-csharp/Web/Web.csproj index 8a2a987c2..a9987ca5b 100755 --- a/equinox-web-csharp/Web/Web.csproj +++ b/equinox-web-csharp/Web/Web.csproj @@ -5,10 +5,10 @@ - - - - + + + + diff --git a/equinox-web/Domain/Domain.fsproj b/equinox-web/Domain/Domain.fsproj index fe88ed63a..0a2745e22 100644 --- a/equinox-web/Domain/Domain.fsproj +++ b/equinox-web/Domain/Domain.fsproj @@ -12,7 +12,7 @@ - + diff --git a/equinox-web/Web/Web.fsproj b/equinox-web/Web/Web.fsproj index 2666e844a..a43aada99 100644 --- a/equinox-web/Web/Web.fsproj +++ b/equinox-web/Web/Web.fsproj @@ -11,10 +11,10 @@ - - - - + + + +