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 new file mode 100755 index 000000000..fb4e7662d --- /dev/null +++ b/equinox-web-csharp/.template.config/template.json @@ -0,0 +1,91 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "@jet @bartelink", + "classifications": [ + "Web", + "Equinox", + "Event Sourcing" + ], + "tags": { + "language": "C#" + }, + "identity": "Equinox.Template.CSharp", + "name": "Equinox Web App", + "shortName": "eqxwebcs", + "sourceName": "TodoBackendTemplate", + "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": "true", + "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" + } + }, + "sources": [ + { + "modifiers": [ + { + "condition": "(!todos)", + "exclude": [ + "*/Controllers/**/*", + "**/Todo.cs", + "**/ClientId.cs", + "README.md" + ] + }, + { + "condition": "(!aggregate)", + "exclude": [ + "**/Aggregate.cs" + ] + }, + { + "condition": "(!memoryStore)", + "exclude": [ + "**/MemoryStoreContext.cs" + ] + }, + { + "condition": "(!eventStore)", + "exclude": [ + "**/EventStoreContext.cs" + ] + }, + { + "condition": "(!cosmos)", + "exclude": [ + "**/CosmosContext.cs" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/equinox-web-csharp/Domain/Aggregate.cs b/equinox-web-csharp/Domain/Aggregate.cs new file mode 100755 index 000000000..fbd527bc8 --- /dev/null +++ b/equinox-web-csharp/Domain/Aggregate.cs @@ -0,0 +1,138 @@ +using Equinox; +using Equinox.Store; +using Microsoft.FSharp.Core; +using Newtonsoft.Json; +using Serilog; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace TodoBackendTemplate +{ + public static class Aggregate + { + /// NB - these types and names reflect the actual storage formats and hence need to be versioned with care + public abstract class Event + { + public class Happened : Event + { + } + + public class Compacted : Event + { + public new bool Happened { get; set; } + } + + static readonly JsonNetUtf8Codec Codec = new JsonNetUtf8Codec(new JsonSerializerSettings()); + + public static Event TryDecode(string et, byte[] json) + { + switch (et) + { + case nameof(Happened): return Codec.Decode(json); + case nameof(Compacted): return Codec.Decode(json); + default: return null; + } + } + + public static (string, byte[]) Encode(Event e) => (e.GetType().Name, Codec.Encode(e)); + } + public class State + { + public bool Happened { get; set; } + + internal State(bool happened) { Happened = happened; } + + public static readonly State Initial = new State(false); + + static void Evolve(State s, Event x) + { + switch (x) + { + case Event.Happened e: + s.Happened = true; + break; + 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) + { + // 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(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 abstract class Command + { + public class MakeItSo : Command + { + } + + public static IEnumerable Interpret(State s, Command x) + { + switch (x) + { + case MakeItSo c: + if (!s.Happened) yield return new Event.Happened(); + break; + default: throw new ArgumentOutOfRangeException(nameof(x), x, "invalid"); + } + } + } + + class Handler + { + readonly EquinoxHandler _inner; + + public Handler(ILogger log, IStream stream) => + _inner = new EquinoxHandler(State.Fold, log, stream); + + /// Execute `command`, syncing any events decided upon + public Task Execute(Command c) => + _inner.Execute(ctx => + 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) => + _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; + + static Target CategoryId(string id) => Target.NewCatId("Aggregate", id); + + public Service(ILogger handlerLog, Func> resolve) => + _stream = id => new Handler(handlerLog, resolve(CategoryId(id))); + + /// Execute the specified command + public Task Execute(string id, Command 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); + + static View Render(State s) => new View() {Sorted = s.Happened}; + } + } +} \ 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..a8ab98a01 --- /dev/null +++ b/equinox-web-csharp/Domain/ClientId.cs @@ -0,0 +1,35 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using System.Runtime.Serialization; + +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; + + [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"); + } + + 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) => + base.ConvertTo(context, culture, value, destinationType); + } +} \ No newline at end of file diff --git a/equinox-web-csharp/Domain/Domain.csproj b/equinox-web-csharp/Domain/Domain.csproj new file mode 100755 index 000000000..985921276 --- /dev/null +++ b/equinox-web-csharp/Domain/Domain.csproj @@ -0,0 +1,14 @@ + + + + netstandard2.0 + false + + + + + + + + + \ 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..47bdca7d4 --- /dev/null +++ b/equinox-web-csharp/Domain/Infrastructure.cs @@ -0,0 +1,70 @@ +using Equinox; +using Equinox.Store; +using Microsoft.FSharp.Collections; +using Microsoft.FSharp.Control; +using Microsoft.FSharp.Core; +using Newtonsoft.Json; +using Serilog; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +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, + int maxAttempts = 3) + : base(FuncConvert.FromFunc(fold), log, stream, maxAttempts) + { + } + + // Run the decision method, letting it decide whether or not the Command's intent should manifest as Events + 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 + 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 + public 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/Todo.cs b/equinox-web-csharp/Domain/Todo.cs new file mode 100755 index 000000000..7e12ecf53 --- /dev/null +++ b/equinox-web-csharp/Domain/Todo.cs @@ -0,0 +1,297 @@ +using Equinox; +using Equinox.Store; +using Microsoft.FSharp.Core; +using Newtonsoft.Json; +using Serilog; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace TodoBackendTemplate +{ + public static class Todo + { + /// NB - these types and names reflect the actual storage formats and hence need to be versioned with care + public abstract class Event + { + /// 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 abstract class ItemEvent : Event + { + public ItemData Data { get; set; } + } + + public class Added : ItemEvent + { + } + + public class Updated : ItemEvent + { + } + + public class Deleted : Event + { + public int Id { get; set; } + } + + public class Cleared : Event + { + public int NextId { get; set; } + } + + public class Compacted : Event + { + public int NextId { get; set; } + public ItemData[] Items { get; set; } + } + + static readonly JsonNetUtf8Codec Codec = new JsonNetUtf8Codec(new JsonSerializerSettings()); + + public static Event 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 (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 + // 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 Event.ItemData[] Items { get; } + + internal State(int nextId, Event.ItemData[] items) + { + NextId = nextId; + Items = items; + } + + 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) + { + var nextId = origin.NextId; + var items = origin.Items.ToList(); + foreach (var x in xs) + switch (x) + { + case Event.Added e: + nextId++; + items.Insert(0, e.Data); + break; + case Event.Updated e: + var i = items.FindIndex(item => item.Id == e.Data.Id); + if (i != -1) + items[i] = e.Data; + break; + case Event.Deleted e: + items.RemoveAll(item => item.Id == e.Id); + break; + case Event.Cleared e: + nextId = e.NextId; + items.Clear(); + break; + case Event.Compacted e: + nextId = e.NextId; + items = e.Items.ToList(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(x), x, "invalid"); + } + 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(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 Event Compact(State state) => new Event.Compacted { NextId = state.NextId, Items = state.Items }; + } + + /// 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 abstract class Command + { + /// Create a single item + public class Add : Command + { + public Props Props { get; set; } + } + + /// Update a single item + public class Update : Command + { + public int Id { get; set; } + public Props Props { get; set; } + } + + /// Delete a single item from the list + public class Delete : Command + { + public int Id { get; set; } + } + + /// Complete clear the todo list + public class Clear : Command + { + } + + /// 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); + break; + case Update c: + var proposed = new {c.Props.Order, c.Props.Title, c.Props.Completed}; + + bool IsEquivalent(Event.ItemData i) => + i.Id == c.Id + && new {i.Order, i.Title, i.Completed} == 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 Event.Deleted {Id = c.Id}; + break; + 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 : 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 + class Handler + { + readonly EquinoxHandler _inner; + + public Handler(ILogger log, IStream stream) => + _inner = new EquinoxHandler(State.Fold, log, stream); + + /// Execute `command`; does not emit the post state + public Task Execute(Command c) => + _inner.Execute(ctx => + 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(Command c) => + _inner.Decide(ctx => + { + ctx.Execute(s => Command.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 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; + + /// 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))); + + // + // READ + // + + /// List all open items + public Task> List(ClientId clientId) => + _stream(clientId).Query(s => s.Items.Select(Render)); + + /// 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); + }); + + // + // WRITE + // + + /// Execute the specified (blind write) command + public Task Execute(ClientId clientId, Command command) => + _stream(clientId).Execute(command); + + // + // WRITE-READ + // + + /// 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 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 Command.Update {Id = id, Props = value}); + return Render(state.Single(x => x.Id == id)); + } + + static View Render(Event.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-csharp/README.md b/equinox-web-csharp/README.md new file mode 100644 index 000000000..b659d0de9 --- /dev/null +++ b/equinox-web-csharp/README.md @@ -0,0 +1,36 @@ +# Equinox Web Template + +This project was generated using: + + dotnet new -i Equinox.Templates # just once, to install in the local templates store + + 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 + + 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 diff --git a/equinox-web-csharp/Web/Controllers/TodosController.cs b/equinox-web-csharp/Web/Controllers/TodosController.cs new file mode 100755 index 000000000..24e1297bf --- /dev/null +++ b/equinox-web-csharp/Web/Controllers/TodosController.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace TodoBackendTemplate.Controllers +{ + public class FromClientIdHeaderAttribute : FromHeaderAttribute + { + public FromClientIdHeaderAttribute() => + Name = "COMPLETELY_INSECURE_CLIENT_ID"; + } + + 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; } + } + + // 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 + { + readonly Todo.Service _service; + + public TodosController(Todo.Service service) => + _service = service; + + [HttpGet] + public async Task> Get([FromClientIdHeader] ClientId clientId) => + from x in await _service.List(clientId) select WithUri(x); + + [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.Command.Delete {Id = id}); + + [HttpDelete] + public Task DeleteAll([FromClientIdHeader] ClientId clientId) => + _service.Execute(clientId, new Todo.Command.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/CosmosContext.cs b/equinox-web-csharp/Web/CosmosContext.cs new file mode 100644 index 000000000..02b2817ac --- /dev/null +++ b/equinox-web-csharp/Web/CosmosContext.cs @@ -0,0 +1,85 @@ +using Equinox; +using Equinox.Cosmos; +using Equinox.UnionCodec; +using Microsoft.FSharp.Control; +using Microsoft.FSharp.Core; +using Serilog; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +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 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); + _connect = async () => + { + var gateway = await Connect("App", config.Mode, discovery, timeout, retriesOn429Throttling, + (int)timeout.TotalSeconds); + var collectionMapping = new EqxCollections(config.Database, config.Collection); + + _store = new EqxStore(gateway, collectionMapping); + }; + } + + internal override async Task Connect() => await _connect(); + + 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 = await FSharpAsync.StartAsTask(c.Connect(appName, discovery), null, null); + return new EqxGateway(conn, new EqxBatchingPolicy(defaultMaxItems: 500)); + } + + public override Func> Resolve( + IUnionEncoder codec, + Func, TState> fold, + TState initial, + 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, codec, FuncConvert.FromFunc(fold), initial, accessStrategy, cacheStrategy); + 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 new file mode 100644 index 000000000..ca49077da --- /dev/null +++ b/equinox-web-csharp/Web/EquinoxContext.cs @@ -0,0 +1,41 @@ +using Equinox; +using Microsoft.FSharp.Core; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using TypeShape; + +namespace TodoBackendTemplate +{ + public abstract class EquinoxContext + { + public abstract Func> Resolve( + Equinox.UnionCodec.IUnionEncoder codec, + Func, TState> fold, + TState initial, + Func isOrigin = null, + Func compact = null); + + internal abstract Task 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 => + 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 new file mode 100644 index 000000000..1c8cc4b22 --- /dev/null +++ b/equinox-web-csharp/Web/EventStoreContext.cs @@ -0,0 +1,74 @@ +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; +using System.Threading.Tasks; + +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 Caching.Cache _cache; + + GesGateway _gateway; + readonly Func _connect; + + public EventStoreContext(EventStoreConfig config) + { + _cache = new Caching.Cache("Es", config.CacheMb); + _connect = async () => _gateway = await Connect(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 = await FSharpAsync.StartAsTask( + c.Establish("Twin", Discovery.NewGossipDns(config.Host), ConnectionStrategy.ClusterTwinPreferSlaveReads), + null, null); + return new GesGateway(conn, new GesBatchingPolicy(maxBatchSize: 500)); + } + + public override Func> Resolve( + IUnionEncoder codec, + Func, TState> fold, + TState initial, + 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, codec, FuncConvert.FromFunc(fold), + initial, accessStrategy, cacheStrategy); + 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 new file mode 100644 index 000000000..018a5e0c4 --- /dev/null +++ b/equinox-web-csharp/Web/MemoryStoreContext.cs @@ -0,0 +1,32 @@ +using Equinox; +using Equinox.MemoryStore; +using Equinox.Store; +using Equinox.UnionCodec; +using Microsoft.FSharp.Core; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace TodoBackendTemplate +{ + public class MemoryStoreContext : EquinoxContext + { + readonly VolatileStore _store; + + public MemoryStoreContext(VolatileStore store) => + _store = store; + + public override Func> Resolve( + IUnionEncoder codec, + Func, TState> fold, + TState initial, + Func isOrigin = null, + Func compact = null) + { + var resolver = new MemResolver(_store, FuncConvert.FromFunc(fold), initial); + return target => resolver.Resolve.Invoke(target); + } + + 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 new file mode 100755 index 000000000..cdf591dd3 --- /dev/null +++ b/equinox-web-csharp/Web/Program.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using Serilog.Events; +using System; + +namespace TodoBackendTemplate.Web +{ + static class Program + { + public static async int Main(string[] argv) + { + try + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); + var host = WebHost + .CreateDefaultBuilder(argv) + .UseStartup() + .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) + { + 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..809149d77 --- /dev/null +++ b/equinox-web-csharp/Web/Startup.cs @@ -0,0 +1,149 @@ +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); + } + + static void ConfigureServices(IServiceCollection services, EquinoxContext context) + { + services.AddSingleton(_ => context); + services.AddSingleton(sp => new ServiceBuilder(context, Serilog.Log.ForContext())); +#if todos + services.AddSingleton(sp => sp.GetRequiredService().CreateTodoService()); +#endif +#if aggregate + services.AddSingleton(sp => sp.GetRequiredService().CreateAggregateService()); +#endif +#if (!aggregate && !todos) + //services.Register(fun sp -> sp.Resolve().CreateThingService()) +#endif + } + + 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 (!cosmos && !eventStore) + return new MemoryStoreContext(new Equinox.MemoryStore.VolatileStore()); +#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 CreateTodoService() => + new Todo.Service( + _handlerLog, + _context.Resolve( + 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.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() => +// 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/ +// Thing.Folds.fold, +// Thing.Folds.initial, +// Thing.Folds.isOrigin, +// 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 new file mode 100755 index 000000000..a9987ca5b --- /dev/null +++ b/equinox-web-csharp/Web/Web.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp2.1 + + + + + + + + + + + + + + + + + \ 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": "*" +} diff --git a/equinox-web-csharp/equinox-web.sln b/equinox-web-csharp/equinox-web.sln new file mode 100755 index 000000000..e3cf252c4 --- /dev/null +++ b/equinox-web-csharp/equinox-web.sln @@ -0,0 +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", "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..e29058f26 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" @@ -12,7 +11,7 @@ }, "identity": "Equinox.Template", "name": "Equinox Web App", - "shortName": "equinoxweb", + "shortName": "eqxweb", "sourceName": "TodoBackendTemplate", "preferNameDirectory": true, @@ -34,7 +33,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/Domain/Domain.fsproj b/equinox-web/Domain/Domain.fsproj index 6c6cc551b..0a2745e22 100644 --- a/equinox-web/Domain/Domain.fsproj +++ b/equinox-web/Domain/Domain.fsproj @@ -12,7 +12,7 @@ - + diff --git a/equinox-web/Domain/TodosService.fs b/equinox-web/Domain/Todo.fs similarity index 89% rename from equinox-web/Domain/TodosService.fs rename to equinox-web/Domain/Todo.fs index 66fab28d2..c8a4391f5 100644 --- a/equinox-web/Domain/TodosService.fs +++ b/equinox-web/Domain/Todo.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) = @@ -90,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 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/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) diff --git a/equinox-web/Web/Web.fsproj b/equinox-web/Web/Web.fsproj index 91599607e..a43aada99 100644 --- a/equinox-web/Web/Web.fsproj +++ b/equinox-web/Web/Web.fsproj @@ -4,10 +4,6 @@ netcoreapp2.1 - - TRACE;todos - - @@ -15,10 +11,10 @@ - - - - + + + + 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