From 82583e2a4fc02d5a9b47ee7e7fc355d641d5c97e Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Tue, 7 Apr 2026 16:45:09 +0100 Subject: [PATCH 1/3] Port to C# 14 --- equinox-web-csharp/Domain/Aggregate.cs | 46 +- equinox-web-csharp/Domain/ClientId.cs | 29 +- equinox-web-csharp/Domain/Domain.csproj | 5 +- equinox-web-csharp/Domain/Infrastructure.cs | 2 - equinox-web-csharp/Domain/Todo.cs | 421 ++++++++---------- .../Web/Controllers/TodosController.cs | 129 +++--- equinox-web-csharp/Web/CosmosContext.cs | 45 +- equinox-web-csharp/Web/EquinoxContext.cs | 11 +- equinox-web-csharp/Web/EventStoreContext.cs | 32 +- equinox-web-csharp/Web/MemoryStoreContext.cs | 17 +- equinox-web-csharp/Web/Program.cs | 165 +++++-- equinox-web-csharp/Web/ServiceBuilder.cs | 61 +++ equinox-web-csharp/Web/Startup.cs | 176 -------- equinox-web-csharp/Web/Web.csproj | 12 +- 14 files changed, 503 insertions(+), 648 deletions(-) create mode 100644 equinox-web-csharp/Web/ServiceBuilder.cs delete mode 100755 equinox-web-csharp/Web/Startup.cs diff --git a/equinox-web-csharp/Domain/Aggregate.cs b/equinox-web-csharp/Domain/Aggregate.cs index 8c1c5a3f9..0e0560046 100755 --- a/equinox-web-csharp/Domain/Aggregate.cs +++ b/equinox-web-csharp/Domain/Aggregate.cs @@ -1,7 +1,4 @@ using Microsoft.FSharp.Core; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; namespace TodoBackendTemplate; @@ -10,9 +7,7 @@ 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 Happened : Event { } public class Snapshotted : Event { @@ -20,7 +15,7 @@ public class Snapshotted : Event } static readonly SystemTextJsonUtf8Codec Codec = new(new()); - + public static FSharpValueOption TryDecode(string et, ReadOnlyMemory json) => et switch { @@ -30,16 +25,17 @@ public static FSharpValueOption TryDecode(string et, ReadOnlyMemory }; public static (string, ReadOnlyMemory) Encode(Event e) => (e.GetType().Name, Codec.Encode(e)); - public const string Category = "Aggregate"; + public const string Category = "Aggregate"; public static string StreamId(ClientId id) => id.ToString(); } + public class State { public bool Happened { get; set; } State(bool happened) { Happened = happened; } - public static readonly State Initial = new (false); + public static readonly State Initial = new(false); static void Evolve(State s, Event x) => s.Happened = x switch @@ -59,47 +55,35 @@ public static State Fold(State origin, IEnumerable xs) } public static bool IsOrigin(Event e) => e is Event.Snapshotted; - - public static Event Snapshot(State s) => new Event.Snapshotted {HasHappened = s.Happened}; + + public static Event Snapshot(State s) => new Event.Snapshotted { HasHappened = 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 + /// 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 class MakeItSo : Command { } public Event[] Interpret(State s) => this switch { - MakeItSo => - s.Happened - ? Array.Empty() - : new Event[] { new Event.Happened() }, + MakeItSo => s.Happened ? [] : [new Event.Happened()], _ => throw new ArgumentOutOfRangeException(nameof(Command), this, "invalid") }; } public record View(bool Sorted); - public class Service + public class Service(Func> resolve) { - /// Maps a ClientId to Handler for the relevant stream - readonly Func> _resolve; - - public Service(Func> resolve) => - _resolve = resolve; - - /// Execute the specified command + /// Execute the specified command public Task Execute(ClientId id, Command command) => - _resolve(id).Transact(command.Interpret); + resolve(id).Transact(command.Interpret); /// 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(ClientId id) => - _resolve(id).Query(Render); + resolve(id).Query(Render); - static View Render(State s) => - new (Sorted: s.Happened); + static View Render(State s) => new(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 index fd9a9a179..f9c3c7bab 100755 --- a/equinox-web-csharp/Domain/ClientId.cs +++ b/equinox-web-csharp/Domain/ClientId.cs @@ -1,34 +1,11 @@ -using System; -using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Runtime.Serialization; +using System.Runtime.Serialization; namespace TodoBackendTemplate; /// ClientId strongly typed id -// To support model binding using aspnetcore 2 FromHeader -[TypeConverter(typeof(ClientIdStringConverter))] -public class ClientId +public class ClientId(Guid value) { - ClientId(Guid value) => Value = value; - [IgnoreDataMember] // Prevent Swashbuckle inferring there is a Value property - [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] - 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 this type is used to Bind to a HTTP Request header - public static ClientId Parse(string input) => new(Guid.Parse(input)); - + public Guid Value { get; } = value; 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); } \ No newline at end of file diff --git a/equinox-web-csharp/Domain/Domain.csproj b/equinox-web-csharp/Domain/Domain.csproj index 03a55d00c..67e8a6bb1 100755 --- a/equinox-web-csharp/Domain/Domain.csproj +++ b/equinox-web-csharp/Domain/Domain.csproj @@ -1,7 +1,9 @@  - net6.0 + net10.0 + enable + enable 5 true @@ -9,7 +11,6 @@ - diff --git a/equinox-web-csharp/Domain/Infrastructure.cs b/equinox-web-csharp/Domain/Infrastructure.cs index fb0d48787..676e55154 100644 --- a/equinox-web-csharp/Domain/Infrastructure.cs +++ b/equinox-web-csharp/Domain/Infrastructure.cs @@ -1,5 +1,3 @@ -using System; - namespace TodoBackendTemplate; /// System.Text.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 ddb179af1..8203a681f 100755 --- a/equinox-web-csharp/Domain/Todo.cs +++ b/equinox-web-csharp/Domain/Todo.cs @@ -1,265 +1,230 @@ using Microsoft.FSharp.Core; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; - -namespace TodoBackendTemplate + +namespace TodoBackendTemplate; + +public static class Todo { - 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 { - /// 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 class ItemData { - /// Information we retain per Todo List entry - public 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; } = new(); - } - - public class Added : ItemEvent - { - } - - public class Updated : ItemEvent - { - } - - public class Deleted : Event - { - public int Id { get; init; } - } - - public class Cleared : Event - { - public int NextId { get; init; } - } - - public class Snapshotted : Event - { - public int NextId { get; init; } - public ItemData[] Items { get; init; } - } - - static readonly SystemTextJsonUtf8Codec Codec = - new (new JsonSerializerOptions()); - - public static FSharpValueOption TryDecode(string et, ReadOnlyMemory json) => - et switch - { - nameof(Added) => Codec.Decode(json), - nameof(Updated) => Codec.Decode(json), - nameof(Deleted) => Codec.Decode(json), - nameof(Cleared) => Codec.Decode(json), - nameof(Snapshotted) => Codec.Decode(json), - _ => FSharpValueOption.None - }; - - public static (string, ReadOnlyMemory) Encode(Event e) => - (e.GetType().Name, Codec.Encode(e)); - - public const string Category = "Todos"; - /// Maps a ClientId to the Target that specifies the Stream in which the data for that client will be held - public static string StreamId(ClientId id) => id?.ToString() ?? "1"; + public int Id { get; set; } + public int Order { get; set; } + public string? Title { get; set; } + public bool Completed { get; set; } } - /// 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 abstract class ItemEvent : Event { - public int NextId { get; } - public Event.ItemData[] Items { get; } + public ItemData Data { get; } = new(); + } - private State(int nextId, Event.ItemData[] items) - { - NextId = nextId; - Items = items; - } + public class Added : ItemEvent { } - public static readonly State Initial = new (0, Array.Empty()); + public class Updated : ItemEvent { } - /// 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.Snapshotted e: - nextId = e.NextId; - items = e.Items.ToList(); - break; - default: - throw new ArgumentOutOfRangeException(nameof(Command), 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.Snapshotted; - - /// Prepares an Event that encodes all relevant aspects of a State such that `evolve` can rehydrate a complete State from it - public static Event Snapshot(State state) => new Event.Snapshotted { NextId = state.NextId, Items = state.Items }; + public class Deleted : Event + { + public int Id { get; init; } } - /// Properties that can be edited on a Todo List item - public class Props + public class Cleared : Event { - public int Order { get; init; } - public string Title { get; init; } - public bool Completed { get; init; } + public int NextId { get; init; } } - /// Defines the operations a caller can perform on a Todo List - public abstract class Command + public class Snapshotted : Event { - /// Create a single item - public class Add : Command - { - public Props Props { get; init; } - } + public int NextId { get; init; } + public required ItemData[] Items { get; init; } + } - /// Update a single item - public class Update : Command - { - public int Id { get; init; } - public Props Props { get; init; } - } + static readonly SystemTextJsonUtf8Codec Codec = new(new ()); - /// Delete a single item from the list - public class Delete : Command + public static FSharpValueOption TryDecode(string et, ReadOnlyMemory json) => + et switch { - public int Id { get; init; } - } + nameof(Added) => Codec.Decode(json), + nameof(Updated) => Codec.Decode(json), + nameof(Deleted) => Codec.Decode(json), + nameof(Cleared) => Codec.Decode(json), + nameof(Snapshotted) => Codec.Decode(json), + _ => FSharpValueOption.None + }; + + public static (string, ReadOnlyMemory) Encode(Event e) => + (e.GetType().Name, Codec.Encode(e)); + + public const string Category = "Todos"; + /// Maps a ClientId to the StreamId that identifies the Stream in which the data for that client will be held + public static string StreamId(ClientId id) => id.ToString(); + } - /// Completely clear the list - public class Clear : Command - { - } + /// 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; } - /// Defines the decision process which maps from the intent of the `Command` to the `Event`s that represent that decision in the Stream - [SuppressMessage("ReSharper", "CoVariantArrayConversion")] - public Event[] Interpret(State s) - { - switch (this) - { - case Add c: - return new[] { Make(s.NextId, c.Props) }; - 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; - - return s.Items.Any(IsEquivalent) - ? Array.Empty() - : new[] { Make(c.Id, c.Props) }; - case Delete c when s.Items.All(i => i.Id != c.Id): - return Array.Empty(); - case Delete c: return new[] { new Event.Deleted {Id = c.Id} }; - case Clear when !s.Items.Any(): return Array.Empty(); - case Clear: return new[] { new Event.Cleared { NextId = s.NextId } }; + State(int nextId, Event.ItemData[] items) + { + NextId = nextId; + Items = items; + } + public static readonly State Initial = new(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.Snapshotted e: + nextId = e.NextId; + items = [.. e.Items]; + break; default: - throw new ArgumentOutOfRangeException(nameof(s), this, "invalid"); + throw new ArgumentOutOfRangeException(nameof(Command), x, "invalid"); } + return new State(nextId, [.. items]); + } + + /// 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 or Event.Snapshotted; + + /// Prepares an Event that encodes all relevant aspects of a State such that `evolve` can rehydrate a complete State from it + public static Event Snapshot(State state) => new Event.Snapshotted { NextId = state.NextId, Items = state.Items }; + } + + /// Properties that can be edited on a Todo List item + public class Props + { + public int Order { get; init; } + public required string? Title { get; init; } + public bool Completed { get; init; } + } - Event 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 the operations a caller can perform on a Todo List + public abstract class Command + { + /// Create a single item + public class Add : Command + { + public required Props Props { get; init; } } - /// A single Item in the Todo List - public class View + /// Update a single item + public class Update : Command { public int Id { get; init; } - public int Order { get; init; } - public string Title { get; init; } - public bool Completed { get; init; } + public required Props Props { get; init; } } - /// Defines operations that a Controller can perform on a Todo List - public class Service + /// Delete a single item from the list + public class Delete : Command + { + public int Id { get; init; } + } + + /// Completely clear the 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 Event[] Interpret(State s) => this switch { - /// Maps a ClientId to Handler for the relevant stream - readonly Func> _resolve; + Add c => [Make(s.NextId, c.Props)], + Update c when s.Items.Any(i => i.Id == c.Id + && new { i.Order, i.Title, i.Completed } == new { c.Props.Order, c.Props.Title, c.Props.Completed }) => [], + Update c => [Make(c.Id, c.Props)], + Delete c when s.Items.All(i => i.Id != c.Id) => [], + Delete c => [new Event.Deleted { Id = c.Id }], + Clear when !s.Items.Any() => [], + Clear => [new Event.Cleared { NextId = s.NextId }], + _ => throw new ArgumentOutOfRangeException(nameof(Command), this, "invalid") + }; + + static Event Make(int id, Props value) where T : Event.ItemEvent, new() => + new T { Data = { Id = id, Order = value.Order, Title = value.Title, Completed = value.Completed } }; + } - public Service(Func> resolve) => - _resolve = resolve; + /// A single Item in the Todo List + public class View + { + public int Id { get; init; } + public int Order { get; init; } + public string? Title { get; init; } + public bool Completed { get; init; } + } - // - // READ - // + /// Defines operations that a Controller can perform on a Todo List + public class Service(Func> resolve) + { + // + // READ + // - /// List all open items - public Task> List(ClientId clientId) => - _resolve(clientId).Query(s => s.Items.Select(Render)); + /// List all open items + public Task> List(ClientId clientId) => + resolve(clientId).Query(s => s.Items.Select(Render)); - /// Load details for a single specific item - public Task TryGet(ClientId clientId, int id) => - _resolve(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) => - _resolve(clientId).Transact(command.Interpret); - - // - // WRITE-READ - // - - /// Create a new ToDo List item; response contains the generated `id` - public Task Create(ClientId clientId, Props template) => - _resolve(clientId).Transact( - new Command.Add {Props = template}.Interpret, - s => Render(s.Items.First())); - - /// Update the specified item as referenced by the `item.id` - public Task Patch(ClientId clientId, int id, Props value) => - _resolve(clientId).Transact( - new Command.Update {Id = id, Props = value}.Interpret, - s => Render(s.Items.Single(x => x.Id == id))); - - static View Render(Event.ItemData i) => - new () {Id = i.Id, Order = i.Order, Title = i.Title, Completed = i.Completed}; - } + /// Load details for a single specific item + public Task TryGet(ClientId clientId, int id) => + resolve(clientId).Query(s => + { + var i = s.Items.SingleOrDefault(x => x.Id == id); + return i is null ? null : Render(i); + }); + + // + // WRITE + // + + /// Execute the specified (blind write) command + public Task Execute(ClientId clientId, Command command) => + resolve(clientId).Transact(command.Interpret); + + // + // WRITE-READ + // + + /// Create a new ToDo List item; response contains the generated `id` + public Task Create(ClientId clientId, Props template) => + resolve(clientId).Transact( + new Command.Add { Props = template }.Interpret, + s => Render(s.Items.First())); + + /// Update the specified item as referenced by the `item.id` + public Task Patch(ClientId clientId, int id, Props value) => + resolve(clientId).Transact( + new Command.Update { Id = id, Props = value }.Interpret, + s => Render(s.Items.Single(x => x.Id == id))); + + static View Render(Event.ItemData i) => + new() { Id = i.Id, Order = i.Order, Title = i.Title, Completed = i.Completed }; } -} +} \ 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 bf308c059..221bcff7c 100755 --- a/equinox-web-csharp/Web/Controllers/TodosController.cs +++ b/equinox-web-csharp/Web/Controllers/TodosController.cs @@ -1,77 +1,84 @@ -using Microsoft.AspNetCore.Mvc; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; -namespace TodoBackendTemplate.Controllers +namespace TodoBackendTemplate.Controllers; + +// Binds ClientId from the COMPLETELY_INSECURE_CLIENT_ID header, defaulting to Guid.Empty when absent +[AttributeUsage(AttributeTargets.Parameter)] +public class FromClientIdHeaderAttribute() : ModelBinderAttribute(typeof(ClientIdModelBinder)) { - public class FromClientIdHeaderAttribute : FromHeaderAttribute - { - public FromClientIdHeaderAttribute() => - Name = "COMPLETELY_INSECURE_CLIENT_ID"; - } + public override BindingSource BindingSource => BindingSource.Header; +} - public class TodoView +sealed class ClientIdModelBinder : IModelBinder +{ + public Task BindModelAsync(ModelBindingContext bindingContext) { - 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; } + var headerValue = bindingContext.HttpContext.Request.Headers["COMPLETELY_INSECURE_CLIENT_ID"].FirstOrDefault(); + var clientId = string.IsNullOrEmpty(headerValue) + ? new ClientId(Guid.Empty) + : new ClientId(Guid.Parse(headerValue)); + bindingContext.Result = ModelBindingResult.Success(clientId); + return Task.CompletedTask; } +} - // Fulfills contract dictated by https://www.todobackend.com - // To run: - // & dotnet run -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; +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; } +} - [HttpGet] - public async Task> Get([FromClientIdHeader] ClientId clientId) => - from x in await _service.List(clientId) select WithUri(x); +// Fulfills contract dictated by https://www.todobackend.com +// To run: +// & dotnet run -p Web +// https://www.todobackend.com/client/index.html?https://localhost:5001/todos +// # NB Jet does not own, control or audit https://todobackend.com; it is a third party site; please satisfy yourself that this is a safe thing to 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(Todo.Service service) : ControllerBase +{ + [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)); - } + [HttpGet("{id}", Name = "GetTodo")] + public async Task Get([FromClientIdHeader] ClientId clientId, int id) + { + var res = await service.TryGet(clientId, id); + if (res is 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))); + [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))); + [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("{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()); + [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}; + static Todo.Props ToProps(TodoView value) => + new() { 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}; - } + 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 index 0195a21e6..37d70ccd4 100644 --- a/equinox-web-csharp/Web/CosmosContext.cs +++ b/equinox-web-csharp/Web/CosmosContext.cs @@ -3,40 +3,24 @@ using FsCodec.SystemTextJson.Interop; using Microsoft.Azure.Cosmos; using Microsoft.FSharp.Core; -using System; -using System.Threading; -using System.Threading.Tasks; namespace TodoBackendTemplate; public record CosmosConfig(ConnectionMode Mode, string ConnectionStringWithUriAndKey, string Database, string Container, int CacheMb); -public class CosmosContext : EquinoxContext +public class CosmosContext(CosmosConfig config) : EquinoxContext { - readonly Cache _cache; + readonly Cache _cache = new("Cosmos", config.CacheMb); + CosmosStoreContext _storeContext = null!; - CosmosStoreContext _context; - readonly Func _connect; - - public CosmosContext(CosmosConfig config) + internal override async Task Connect() { - _cache = new Cache("Cosmos", config.CacheMb); - var retriesOn429Throttling = 1; // Number of retries before failing processing when provisioned RU/s limit in CosmosDb is breached + const int 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.NewConnectionString(config.ConnectionStringWithUriAndKey); - _connect = async () => - { - var connector = new CosmosStoreConnector(discovery, retriesOn429Throttling, timeout, config.Mode); - _context = await Connect(connector, config.Database, config.Container); - }; - } - - internal override async Task Connect() => await _connect(); - - static async Task Connect(CosmosStoreConnector connector, string databaseId, string containerId) - { - var client = await connector.ConnectAsync(new [] {(databaseId,containerId)}, new CancellationToken()); - return new CosmosStoreContext(client, databaseId, containerId, tipMaxEvents: 256); + var connector = new CosmosStoreConnector(discovery, retriesOn429Throttling, timeout, config.Mode); + var client = await connector.ConnectAsync([(config.Database, config.Container)], new CancellationToken()); + _storeContext = new CosmosStoreContext(client, config.Database, config.Container, tipMaxEvents: 256); } public override Func> Resolve( @@ -45,18 +29,15 @@ public override Func> Resolve, Unit> codec, Func fold, TState initial, - Func isOrigin = null, - Func toSnapshot = null) + Func? isOrigin = null, + Func? toSnapshot = null) { var accessStrategy = isOrigin == null && toSnapshot == null ? null - : AccessStrategy.NewSnapshot(FuncConvert.FromFunc(isOrigin), FuncConvert.FromFunc(toSnapshot)); - - var cacheStrategy = _cache == null - ? null - : CachingStrategy.NewSlidingWindow(_cache, TimeSpan.FromMinutes(20)); - var cat = new CosmosStoreCategory(_context, name, FsCodec.SystemTextJson.Encoder.CompressedUtf8(codec), fold, initial, accessStrategy, cacheStrategy); + : AccessStrategy.NewSnapshot(FuncConvert.FromFunc(isOrigin!), FuncConvert.FromFunc(toSnapshot!)); + var cacheStrategy = CachingStrategy.NewSlidingWindow(_cache, TimeSpan.FromMinutes(20)); + var cat = new CosmosStoreCategory(_storeContext, name, FsCodec.SystemTextJson.Encoder.CompressedUtf8(codec), fold, initial, accessStrategy, cacheStrategy); return cat.Resolve(handlerLog); } } \ No newline at end of file diff --git a/equinox-web-csharp/Web/EquinoxContext.cs b/equinox-web-csharp/Web/EquinoxContext.cs index 2f903875d..caa07c830 100644 --- a/equinox-web-csharp/Web/EquinoxContext.cs +++ b/equinox-web-csharp/Web/EquinoxContext.cs @@ -1,7 +1,5 @@ using Microsoft.FSharp.Core; -using System; using System.Text.Json; -using System.Threading.Tasks; namespace TodoBackendTemplate; @@ -13,8 +11,8 @@ public abstract class EquinoxContext FsCodec.IEventCodec, Unit> codec, Func fold, TState initial, - Func isOrigin = null, - Func toSnapshot = null); + Func? isOrigin = null, + Func? toSnapshot = null); internal abstract Task Connect(); } @@ -23,10 +21,9 @@ public static class EquinoxCodec { public static FsCodec.IEventCodec, Unit> Create( Func)> encode, - Func, FSharpValueOption> tryDecode) where TEvent: class => - + Func, FSharpValueOption> tryDecode) where TEvent : class => FsCodec.Codec.Create(encode, tryDecode); - public static FsCodec.IEventCodec, Unit> Create(JsonSerializerOptions options = null) where TEvent: TypeShape.UnionContract.IUnionContract => + public static FsCodec.IEventCodec, Unit> Create(JsonSerializerOptions? options = null) where TEvent : TypeShape.UnionContract.IUnionContract => FsCodec.SystemTextJson.Codec.Create(options); } \ No newline at end of file diff --git a/equinox-web-csharp/Web/EventStoreContext.cs b/equinox-web-csharp/Web/EventStoreContext.cs index 76db792b0..5fd3f73a9 100644 --- a/equinox-web-csharp/Web/EventStoreContext.cs +++ b/equinox-web-csharp/Web/EventStoreContext.cs @@ -1,32 +1,22 @@ using Equinox; using Equinox.EventStoreDb; using Microsoft.FSharp.Core; -using System; -using System.Threading.Tasks; namespace TodoBackendTemplate; public record EventStoreConfig(string ConnectionString, int CacheMb); -public class EventStoreContext : EquinoxContext +public class EventStoreContext(EventStoreConfig config) : EquinoxContext { - readonly Cache _cache; + readonly Cache _cache = new("Es", config.CacheMb); + Equinox.EventStoreDb.EventStoreContext _connection = null!; - Equinox.EventStoreDb.EventStoreContext _connection; - readonly Func _connect; + internal override async Task Connect() => + _connection = await ConnectStore(config); - public EventStoreContext(EventStoreConfig config) - { - _cache = new Cache("Es", config.CacheMb); - _connect = async () => _connection = await Connect(config); - } - - internal override async Task Connect() => await _connect(); - - static Task Connect(EventStoreConfig config) + static Task ConnectStore(EventStoreConfig config) { var c = new EventStoreConnector(reqTimeout: TimeSpan.FromSeconds(5)); - var conn = c.Establish("Twin", Discovery.NewConnectionString(config.ConnectionString), ConnectionStrategy.ClusterTwinPreferSlaveReads); return Task.FromResult(new Equinox.EventStoreDb.EventStoreContext(conn)); } @@ -37,16 +27,14 @@ public override Func> Resolve, Unit> codec, Func fold, TState initial, - Func isOrigin = null, - Func toSnapshot = null) + Func? isOrigin = null, + Func? toSnapshot = null) { var accessStrategy = isOrigin == null && toSnapshot == null ? null - : AccessStrategy.NewRollingSnapshots(FuncConvert.FromFunc(isOrigin), FuncConvert.FromFunc(toSnapshot)); - var cacheStrategy = _cache == null - ? null - : CachingStrategy.NewSlidingWindow(_cache, TimeSpan.FromMinutes(20)); + : AccessStrategy.NewRollingSnapshots(FuncConvert.FromFunc(isOrigin!), FuncConvert.FromFunc(toSnapshot!)); + var cacheStrategy = CachingStrategy.NewSlidingWindow(_cache, TimeSpan.FromMinutes(20)); var cat = new EventStoreCategory(_connection, name, codec, fold, initial, accessStrategy, cacheStrategy); return cat.Resolve(handlerLog); } diff --git a/equinox-web-csharp/Web/MemoryStoreContext.cs b/equinox-web-csharp/Web/MemoryStoreContext.cs index 57358bd2b..e43a52e8d 100644 --- a/equinox-web-csharp/Web/MemoryStoreContext.cs +++ b/equinox-web-csharp/Web/MemoryStoreContext.cs @@ -1,28 +1,21 @@ using Equinox; using Equinox.MemoryStore; using Microsoft.FSharp.Core; -using System; -using System.Threading.Tasks; namespace TodoBackendTemplate; -public class MemoryStoreContext : EquinoxContext +public class MemoryStoreContext(VolatileStore> store) : EquinoxContext { - readonly VolatileStore> _store; - - public MemoryStoreContext(VolatileStore> store) => - _store = store; - public override Func> Resolve( string name, Serilog.ILogger handlerLog, FsCodec.IEventCodec, Unit> codec, - Func fold, + Func fold, TState initial, - Func isOrigin = null, - Func toSnapshot = null) + Func? isOrigin = null, + Func? toSnapshot = null) { - var cat = new MemoryStoreCategory, Unit>(_store, name, codec, fold, initial); + var cat = new MemoryStoreCategory, Unit>(store, name, codec, fold, initial); return cat.Resolve(handlerLog); } diff --git a/equinox-web-csharp/Web/Program.cs b/equinox-web-csharp/Web/Program.cs index 0e6b47e0e..d1a5b88d8 100755 --- a/equinox-web-csharp/Web/Program.cs +++ b/equinox-web-csharp/Web/Program.cs @@ -1,52 +1,127 @@ -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; +using Prometheus; using Serilog; using Serilog.Events; -using System; -using System.Threading.Tasks; +using TodoBackendTemplate; +using TodoBackendTemplate.Web; -namespace TodoBackendTemplate.Web +const string AppName = "TodoBackendTemplate"; + +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) +//#if cosmos + .WriteTo.Sink(new Equinox.CosmosStore.Prometheus.LogSink([Tuple.Create("app", AppName)])) +//#endif + .Enrich.WithProperty("app", AppName) + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); + +try { - static class Logging - { - static Tuple[] CustomTags(string appName) => new[] { Tuple.Create("app", appName) }; - public static LoggerConfiguration Configure(this LoggerConfiguration c, string appName) => - c - .MinimumLevel.Debug() - .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) -#if cosmos - .WriteTo.Sink(new Equinox.CosmosStore.Prometheus.LogSink(CustomTags(appName))) -#endif - .Enrich.FromLogContext() - .WriteTo.Console(); - } - static class Program - { - private const string AppName = "TodoBackendTemplate"; + var builder = WebApplication.CreateBuilder(args); + builder.Host.UseSerilog(); - public static async Task Main(string[] argv) + builder.Services + .AddMvc() + .AddJsonOptions(o => { - try - { - Log.Logger = new LoggerConfiguration().Configure(AppName).CreateLogger(); - var host = WebHost - .CreateDefaultBuilder(argv) - .UseSerilog() - .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; - } - } - } + foreach (var c in FsCodec.SystemTextJson.Options.Default.Converters) + o.JsonSerializerOptions.Converters.Add(c); + }); +//#if todos + builder.Services.AddCors(); +//#endif + + var equinoxContext = ConfigureStore(); + builder.Services.AddSingleton(_ => equinoxContext); + builder.Services.AddSingleton(sp => new ServiceBuilder(equinoxContext, Serilog.Log.ForContext())); +//#if todos + builder.Services.AddSingleton(sp => sp.GetRequiredService().CreateTodoService()); +//#endif +#if aggregate + builder.Services.AddSingleton(sp => sp.GetRequiredService().CreateAggregateService()); +#endif +#if (!aggregate && !todos) + //builder.Services.AddSingleton(sp => sp.GetRequiredService().CreateThingService()); +#endif + + var app = builder.Build(); + + if (app.Environment.IsDevelopment()) + app.UseDeveloperExceptionPage(); + else + app.UseHsts(); + + app.UseHttpsRedirection() + .UseSerilogRequestLogging() // see https://nblumhardt.com/2019/10/serilog-in-aspnetcore-3/ +#if todos + // NB Jet does not own, control or audit https://todobackend.com; it is a third party site; please satisfy yourself that this is a safe thing to use in your environment before using it. + .UseCors(x => x.WithOrigins("https://www.todobackend.com").AllowAnyHeader().AllowAnyMethod()) +#endif + ; + +// add controllers from this assembly + app.MapControllers(); + app.MapMetrics(); // Host /metrics for Prometheus + + foreach (var ctx in app.Services.GetServices()) + await ctx.Connect(); + + await app.RunAsync(); + return 0; +} +catch (Exception e) +{ + Console.Error.WriteLine(e.Message); + return 1; +} + +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 Chocolatey 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("esdb://admin:changeit@localhost:2111,localhost:2112,localhost:2113?tls=true&tlsVerifyCert=false", 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_CONTAINER + // 2) Provision a container using the following command sequence: + // dotnet tool install -g Equinox.Tool + // eqx init -ru 400 cosmos -s $env:EQUINOX_COSMOS_CONNECTION -d $env:EQUINOX_COSMOS_DATABASE -c $env:EQUINOX_COSMOS_CONTAINER + const string connVar = "EQUINOX_COSMOS_CONNECTION"; + var conn = Environment.GetEnvironmentVariable(connVar); + const string dbVar = "EQUINOX_COSMOS_DATABASE"; + var db = Environment.GetEnvironmentVariable(dbVar); + const string containerVar = "EQUINOX_COSMOS_CONTAINER"; + var container = Environment.GetEnvironmentVariable(containerVar); + if (conn == null || db == null || container == null) + throw new Exception( + $"Event Storage subsystem requires the following Environment Variables to be specified: {connVar} {dbVar}, {containerVar}"); + var connMode = Microsoft.Azure.Cosmos.ConnectionMode.Direct; + var config = new CosmosConfig(connMode, conn, db, container, cacheMb); + return new CosmosContext(config); +#endif +#if (!cosmos && !dynamo && !eventStore) + return new MemoryStoreContext(new Equinox.MemoryStore.VolatileStore>()); +#endif +#if (!memoryStore && !cosmos && !dynamo && !eventStore) + //return new MemoryStoreContext(new Equinox.MemoryStore.VolatileStore()); +#endif } \ No newline at end of file diff --git a/equinox-web-csharp/Web/ServiceBuilder.cs b/equinox-web-csharp/Web/ServiceBuilder.cs new file mode 100644 index 000000000..8755e57e8 --- /dev/null +++ b/equinox-web-csharp/Web/ServiceBuilder.cs @@ -0,0 +1,61 @@ +namespace TodoBackendTemplate.Web; + +/// 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 Serilog.ILogger _handlerLog; + + public ServiceBuilder(EquinoxContext context, Serilog.ILogger handlerLog) + { + _context = context; + _handlerLog = handlerLog; + } + +#if todos + public Todo.Service CreateTodoService() + { + var resolve = + _context.Resolve( + Todo.Event.Category, + _handlerLog, + EquinoxCodec.Create(Todo.Event.Encode, Todo.Event.TryDecode), + Todo.State.Fold, + Todo.State.Initial, + Todo.State.IsOrigin, + Todo.State.Snapshot); + return new(id => resolve(Todo.Event.StreamId(id))); + } + +#endif +#if aggregate + public Aggregate.Service CreateAggregateService() + { + var resolve = + _context.Resolve( + Aggregate.Event.Category, + _handlerLog, + EquinoxCodec.Create(Aggregate.Event.Encode, Aggregate.Event.TryDecode), + Aggregate.State.Fold, + Aggregate.State.Initial, + Aggregate.State.IsOrigin, + Aggregate.State.Snapshot); + return new Aggregate.Service(id => resolve(Aggregate.Event.StreamId(id))); + } +#endif +#if (!aggregate && !todos) +// public Thing.Service CreateThingService() +// { +// var resolve = +// _context.Resolve( +// Thing.Event.Category, +// _handlerLog, +// EquinoxCodec.Create(Thing.Event.Encode, Thing.Event.TryDecode), +// Thing.Fold.Fold, +// Thing.Fold.Initial, +// Thing.Fold.IsOrigin, +// Thing.Fold.Snapshot); +// return new Thing.Service(id => resolve(Thing.Event.StreamId(id))); +// } +#endif +} diff --git a/equinox-web-csharp/Web/Startup.cs b/equinox-web-csharp/Web/Startup.cs deleted file mode 100755 index 31d95ea15..000000000 --- a/equinox-web-csharp/Web/Startup.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing.Matching; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Prometheus; -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, IHostEnvironment env) - { - if (env.IsDevelopment()) - app.UseDeveloperExceptionPage(); - else - app.UseHsts(); - - app.UseHttpsRedirection() - .UseRouting() - .UseSerilogRequestLogging() // see https://nblumhardt.com/2019/10/serilog-in-aspnetcore-3/ - -#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 - .UseEndpoints(endpoints => - { - endpoints.MapControllers(); - endpoints.MapMetrics(); // Host /metrics for Prometheus - }); - } - - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services - .AddMvc() - .AddJsonOptions(o => - { - foreach(var c in FsCodec.SystemTextJson.Options.Default.Converters) - o.JsonSerializerOptions.Converters.Add(c); - }); - 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 Chocolatey 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("esdb://admin:changeit@localhost:2111,localhost:2112,localhost:2113?tls=true&tlsVerifyCert=false", 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_CONTAINER - // 2) Provision a container using the following command sequence: - // dotnet tool install -g Equinox.Tool - // eqx init -ru 400 cosmos -s $env:EQUINOX_COSMOS_CONNECTION -d $env:EQUINOX_COSMOS_DATABASE -c $env:EQUINOX_COSMOS_CONTAINER - const string connVar = "EQUINOX_COSMOS_CONNECTION"; - var conn = Environment.GetEnvironmentVariable(connVar); - const string dbVar = "EQUINOX_COSMOS_DATABASE"; - var db = Environment.GetEnvironmentVariable(dbVar); - const string containerVar = "EQUINOX_COSMOS_CONTAINER"; - var container = Environment.GetEnvironmentVariable(containerVar); - if (conn == null || db == null || container == null) - throw new Exception( - $"Event Storage subsystem requires the following Environment Variables to be specified: {connVar} {dbVar}, {containerVar}"); - var connMode = Microsoft.Azure.Cosmos.ConnectionMode.Direct; - var config = new CosmosConfig(connMode, conn, db, container, cacheMb); - return new CosmosContext(config); -#endif -#if (!cosmos && !dynamo && !eventStore) - return new MemoryStoreContext(new Equinox.MemoryStore.VolatileStore>()); -#endif -#if (!memoryStore && !cosmos && !dynamo && !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() - { - var resolve = - _context.Resolve( - Todo.Event.Category, - _handlerLog, - EquinoxCodec.Create(Todo.Event.Encode, Todo.Event.TryDecode), - Todo.State.Fold, - Todo.State.Initial, - Todo.State.IsOrigin, - Todo.State.Snapshot); - return new(id => resolve(Todo.Event.StreamId(id))); - } - -#endif -#if aggregate - public Aggregate.Service CreateAggregateService() - { - var resolve = - _context.Resolve( - Aggregate.Event.Category, - _handlerLog, - EquinoxCodec.Create(Aggregate.Event.Encode, Aggregate.Event.TryDecode), - Aggregate.State.Fold, - Aggregate.State.Initial, - Aggregate.State.IsOrigin, - Aggregate.State.Snapshot); - return new Aggregate.Service(id => resolve(Aggregate.Event.StreamId(id))); - } -#endif -#if (!aggregate && !todos) -// public Thing.Service CreateThingService() -// { -// var resolve = -// _context.Resolve( -// Thing.Event.Category. -// _handlerLog, -// EquinoxCodec.Create(Thing.Event.Encode, Thing.Event.TryDecode), -// Thing.Fold.Fold, -// Thing.Fold.Initial, -// Thing.Fold.IsOrigin, -// Thing.Fold.Snapshot)); -// return new Thing.Service(id => resolve(Thing.Event.StreamId(id))); -// } -#endif - } -} diff --git a/equinox-web-csharp/Web/Web.csproj b/equinox-web-csharp/Web/Web.csproj index 8bf0ae683..e80453f46 100755 --- a/equinox-web-csharp/Web/Web.csproj +++ b/equinox-web-csharp/Web/Web.csproj @@ -1,9 +1,12 @@  - net6.0 + net10.0 + enable + enable 5 true + todos @@ -11,9 +14,10 @@ - - - + + + + From 717c137b2de9d21c5807444a564bd1c160c45c2c Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Tue, 7 Apr 2026 17:42:11 +0100 Subject: [PATCH 2/3] Fix, polish codec scheme --- equinox-web-csharp/Domain/Aggregate.cs | 31 ++++--- equinox-web-csharp/Domain/Infrastructure.cs | 15 ++-- equinox-web-csharp/Domain/Todo.cs | 82 +++++++------------ .../Web/Controllers/TodosController.cs | 10 +-- equinox-web-csharp/Web/EquinoxContext.cs | 5 +- equinox-web-csharp/Web/MemoryStoreContext.cs | 2 +- equinox-web-csharp/Web/Program.cs | 5 +- equinox-web-csharp/Web/ServiceBuilder.cs | 6 +- equinox-web-csharp/Web/Web.csproj | 1 - 9 files changed, 63 insertions(+), 94 deletions(-) diff --git a/equinox-web-csharp/Domain/Aggregate.cs b/equinox-web-csharp/Domain/Aggregate.cs index 0e0560046..219bae5be 100755 --- a/equinox-web-csharp/Domain/Aggregate.cs +++ b/equinox-web-csharp/Domain/Aggregate.cs @@ -4,27 +4,24 @@ namespace TodoBackendTemplate; 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 abstract class Event + public static class Event { - public class Happened : Event { } + public record Happened : IEvent { } + public record Snapshotted(bool HasHappened) : IEvent; - public class Snapshotted : Event - { - public bool HasHappened { get; set; } - } - - static readonly SystemTextJsonUtf8Codec Codec = new(new()); + static readonly SystemTextJsonUtf8Codec Codec = new(new System.Text.Json.JsonSerializerOptions()); - public static FSharpValueOption TryDecode(string et, ReadOnlyMemory json) => + public static IEvent? TryDecode(string et, ReadOnlyMemory json) => et switch { nameof(Happened) => Codec.Decode(json), nameof(Snapshotted) => Codec.Decode(json), - _ => FSharpValueOption.None + _ => null }; - public static (string, ReadOnlyMemory) Encode(Event e) => (e.GetType().Name, Codec.Encode(e)); + public static (string, ReadOnlyMemory) Encode(IEvent e) => (e.GetType().Name, Codec.Encode(e)); public const string Category = "Aggregate"; public static string StreamId(ClientId id) => id.ToString(); } @@ -37,7 +34,7 @@ public class State public static readonly State Initial = new(false); - static void Evolve(State s, Event x) => + static void Evolve(State s, IEvent x) => s.Happened = x switch { Event.Happened => true, @@ -45,7 +42,7 @@ static void Evolve(State s, Event x) => _ => throw new ArgumentOutOfRangeException(nameof(x), x, "invalid") }; - public static State Fold(State origin, IEnumerable xs) + public static State Fold(State origin, IEnumerable xs) { // NB Fold must not mutate the origin var s = new State(origin.Happened); @@ -54,9 +51,9 @@ public static State Fold(State origin, IEnumerable xs) return s; } - public static bool IsOrigin(Event e) => e is Event.Snapshotted; + public static bool IsOrigin(IEvent e) => e is Event.Snapshotted; - public static Event Snapshot(State s) => new Event.Snapshotted { HasHappened = s.Happened }; + public static IEvent Snapshot(State s) => new Event.Snapshotted(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 @@ -64,7 +61,7 @@ public abstract class Command { public class MakeItSo : Command { } - public Event[] Interpret(State s) => this switch + public IEvent[] Interpret(State s) => this switch { MakeItSo => s.Happened ? [] : [new Event.Happened()], _ => throw new ArgumentOutOfRangeException(nameof(Command), this, "invalid") @@ -73,7 +70,7 @@ public class MakeItSo : Command { } public record View(bool Sorted); - public class Service(Func> resolve) + public class Service(Func> resolve) { /// Execute the specified command public Task Execute(ClientId id, Command command) => diff --git a/equinox-web-csharp/Domain/Infrastructure.cs b/equinox-web-csharp/Domain/Infrastructure.cs index 676e55154..af893108d 100644 --- a/equinox-web-csharp/Domain/Infrastructure.cs +++ b/equinox-web-csharp/Domain/Infrastructure.cs @@ -1,14 +1,11 @@ namespace TodoBackendTemplate; /// System.Text.Json implementation of IEncoder that encodes direct to a UTF-8 Buffer -public class SystemTextJsonUtf8Codec +public class SystemTextJsonUtf8Codec(TypeShape.UnionContract.IEncoder> codec) { - readonly TypeShape.UnionContract.IEncoder> _codec; - - public SystemTextJsonUtf8Codec(System.Text.Json.JsonSerializerOptions options) => - _codec = new FsCodec.SystemTextJson.Core.ReadOnlyMemoryEncoder(new FsCodec.SystemTextJson.Serdes(options)); - - public ReadOnlyMemory Encode(T value) => _codec.Encode(value); - - public T Decode(ReadOnlyMemory json) => _codec.Decode(json); + SystemTextJsonUtf8Codec(FsCodec.SystemTextJson.Serdes serdes) : this(new FsCodec.SystemTextJson.Core.ReadOnlyMemoryEncoder(serdes)) { } + public SystemTextJsonUtf8Codec(System.Text.Json.JsonSerializerOptions options) : this(new FsCodec.SystemTextJson.Serdes(options)) { } + public ReadOnlyMemory Encode(object value) => EncodeTyped(value); + public ReadOnlyMemory EncodeTyped(T value) => codec.Encode(value); + public T Decode(ReadOnlyMemory json) => codec.Decode(json); } \ No newline at end of file diff --git a/equinox-web-csharp/Domain/Todo.cs b/equinox-web-csharp/Domain/Todo.cs index 8203a681f..01183a922 100755 --- a/equinox-web-csharp/Domain/Todo.cs +++ b/equinox-web-csharp/Domain/Todo.cs @@ -5,45 +5,19 @@ 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 + public interface IEvent { } + public static class Event { /// Information we retain per Todo List entry - public 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; } = new(); - } - - public class Added : ItemEvent { } - - public class Updated : ItemEvent { } - - public class Deleted : Event - { - public int Id { get; init; } - } - - public class Cleared : Event - { - public int NextId { get; init; } - } - - public class Snapshotted : Event - { - public int NextId { get; init; } - public required ItemData[] Items { get; init; } - } - - static readonly SystemTextJsonUtf8Codec Codec = new(new ()); - - public static FSharpValueOption TryDecode(string et, ReadOnlyMemory json) => + public record ItemData(int Id, int Order, string Title, bool Completed); + public record Added(ItemData Data) : IEvent; + public record Updated(ItemData Data) : IEvent; + public record Deleted(int Id) : IEvent; + public record Cleared(int NextId) : IEvent; + public record Snapshotted(int NextId, ItemData[] Items) : IEvent; + static readonly SystemTextJsonUtf8Codec Codec = new(new System.Text.Json.JsonSerializerOptions()); + + public static IEvent? TryDecode(string et, ReadOnlyMemory json) => et switch { nameof(Added) => Codec.Decode(json), @@ -51,11 +25,14 @@ public static FSharpValueOption TryDecode(string et, ReadOnlyMemory nameof(Deleted) => Codec.Decode(json), nameof(Cleared) => Codec.Decode(json), nameof(Snapshotted) => Codec.Decode(json), - _ => FSharpValueOption.None + _ => null }; - public static (string, ReadOnlyMemory) Encode(Event e) => - (e.GetType().Name, Codec.Encode(e)); + public static (string, ReadOnlyMemory) Encode(IEvent e) + { + var utf8 = Codec.Encode(e); + return (e.GetType().Name, utf8); + } public const string Category = "Todos"; /// Maps a ClientId to the StreamId that identifies the Stream in which the data for that client will be held @@ -81,7 +58,7 @@ public class State public static readonly State Initial = new(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(); @@ -115,17 +92,17 @@ 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(Event e) => e is Event.Cleared or Event.Snapshotted; + public static bool IsOrigin(IEvent e) => e is Event.Cleared or Event.Snapshotted; /// Prepares an Event that encodes all relevant aspects of a State such that `evolve` can rehydrate a complete State from it - public static Event Snapshot(State state) => new Event.Snapshotted { NextId = state.NextId, Items = state.Items }; + public static IEvent Snapshot(State state) => new Event.Snapshotted(state.NextId, state.Items); } /// Properties that can be edited on a Todo List item public class Props { public int Order { get; init; } - public required string? Title { get; init; } + public required string Title { get; init; } public bool Completed { get; init; } } @@ -155,21 +132,20 @@ public class Delete : Command 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 Event[] Interpret(State s) => this switch + public IEvent[] Interpret(State s) => this switch { - Add c => [Make(s.NextId, c.Props)], + Add c => [new Event.Added(Make(s.NextId, c.Props))], Update c when s.Items.Any(i => i.Id == c.Id && new { i.Order, i.Title, i.Completed } == new { c.Props.Order, c.Props.Title, c.Props.Completed }) => [], - Update c => [Make(c.Id, c.Props)], + Update c => [new Event.Updated(Make(c.Id, c.Props))], Delete c when s.Items.All(i => i.Id != c.Id) => [], - Delete c => [new Event.Deleted { Id = c.Id }], + Delete c => [new Event.Deleted(c.Id)], Clear when !s.Items.Any() => [], - Clear => [new Event.Cleared { NextId = s.NextId }], + Clear => [new Event.Cleared(s.NextId)], _ => throw new ArgumentOutOfRangeException(nameof(Command), this, "invalid") }; - static Event Make(int id, Props value) where T : Event.ItemEvent, new() => - new T { Data = { Id = id, Order = value.Order, Title = value.Title, Completed = value.Completed } }; + static Event.ItemData Make(int id, Props value) => new Event.ItemData(id, value.Order, value.Title, value.Completed); } /// A single Item in the Todo List @@ -177,12 +153,12 @@ public class View { public int Id { get; init; } public int Order { get; init; } - public string? Title { get; init; } + public required string Title { get; init; } public bool Completed { get; init; } } /// Defines operations that a Controller can perform on a Todo List - public class Service(Func> resolve) + public class Service(Func> resolve) { // // READ diff --git a/equinox-web-csharp/Web/Controllers/TodosController.cs b/equinox-web-csharp/Web/Controllers/TodosController.cs index 221bcff7c..1eccc8042 100755 --- a/equinox-web-csharp/Web/Controllers/TodosController.cs +++ b/equinox-web-csharp/Web/Controllers/TodosController.cs @@ -13,14 +13,11 @@ public class FromClientIdHeaderAttribute() : ModelBinderAttribute(typeof(ClientI sealed class ClientIdModelBinder : IModelBinder { - public Task BindModelAsync(ModelBindingContext bindingContext) + public async Task BindModelAsync(ModelBindingContext bindingContext) { var headerValue = bindingContext.HttpContext.Request.Headers["COMPLETELY_INSECURE_CLIENT_ID"].FirstOrDefault(); - var clientId = string.IsNullOrEmpty(headerValue) - ? new ClientId(Guid.Empty) - : new ClientId(Guid.Parse(headerValue)); + ClientId clientId = Guid.TryParse(headerValue, out var res) ? new (res) : new (Guid.Empty); bindingContext.Result = ModelBindingResult.Success(clientId); - return Task.CompletedTask; } } @@ -73,7 +70,8 @@ public Task DeleteAll([FromClientIdHeader] ClientId clientId) => service.Execute(clientId, new Todo.Command.Clear()); static Todo.Props ToProps(TodoView value) => - new() { Order = value.Order, Title = value.Title, Completed = value.Completed }; + // TODO PATCH passes a view without a Title - the intended semantics is probably to have it fully sparse (but the todobackend spec does not provide a test t make that clear) + new() { Order = value.Order, Title = value.Title ?? "", Completed = value.Completed }; TodoView WithUri(Todo.View x) { diff --git a/equinox-web-csharp/Web/EquinoxContext.cs b/equinox-web-csharp/Web/EquinoxContext.cs index caa07c830..414c105f8 100644 --- a/equinox-web-csharp/Web/EquinoxContext.cs +++ b/equinox-web-csharp/Web/EquinoxContext.cs @@ -21,9 +21,8 @@ public static class EquinoxCodec { public static FsCodec.IEventCodec, Unit> Create( Func)> encode, - Func, FSharpValueOption> tryDecode) where TEvent : class => - FsCodec.Codec.Create(encode, tryDecode); - + Func, TEvent?> tryDecode) => + FsCodec.Codec.Create(encode, (s, b) => tryDecode(s,b) ?? FSharpValueOption.None); public static FsCodec.IEventCodec, Unit> Create(JsonSerializerOptions? options = null) where TEvent : TypeShape.UnionContract.IUnionContract => FsCodec.SystemTextJson.Codec.Create(options); } \ No newline at end of file diff --git a/equinox-web-csharp/Web/MemoryStoreContext.cs b/equinox-web-csharp/Web/MemoryStoreContext.cs index e43a52e8d..919b177e4 100644 --- a/equinox-web-csharp/Web/MemoryStoreContext.cs +++ b/equinox-web-csharp/Web/MemoryStoreContext.cs @@ -19,5 +19,5 @@ public override Func> Resolve Task.CompletedTask; + internal override async Task Connect() {} } \ No newline at end of file diff --git a/equinox-web-csharp/Web/Program.cs b/equinox-web-csharp/Web/Program.cs index d1a5b88d8..4a5b54fbc 100755 --- a/equinox-web-csharp/Web/Program.cs +++ b/equinox-web-csharp/Web/Program.cs @@ -26,6 +26,10 @@ .AddMvc() .AddJsonOptions(o => { + o.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; + o.JsonSerializerOptions.DictionaryKeyPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; + o.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + foreach (var c in FsCodec.SystemTextJson.Options.Default.Converters) o.JsonSerializerOptions.Converters.Add(c); }); @@ -61,7 +65,6 @@ #endif ; -// add controllers from this assembly app.MapControllers(); app.MapMetrics(); // Host /metrics for Prometheus diff --git a/equinox-web-csharp/Web/ServiceBuilder.cs b/equinox-web-csharp/Web/ServiceBuilder.cs index 8755e57e8..1a0ff31e4 100644 --- a/equinox-web-csharp/Web/ServiceBuilder.cs +++ b/equinox-web-csharp/Web/ServiceBuilder.cs @@ -19,7 +19,7 @@ public Todo.Service CreateTodoService() _context.Resolve( Todo.Event.Category, _handlerLog, - EquinoxCodec.Create(Todo.Event.Encode, Todo.Event.TryDecode), + EquinoxCodec.Create(Todo.Event.Encode, Todo.Event.TryDecode), Todo.State.Fold, Todo.State.Initial, Todo.State.IsOrigin, @@ -35,7 +35,7 @@ public Aggregate.Service CreateAggregateService() _context.Resolve( Aggregate.Event.Category, _handlerLog, - EquinoxCodec.Create(Aggregate.Event.Encode, Aggregate.Event.TryDecode), + EquinoxCodec.Create(Aggregate.Event.Encode, Aggregate.Event.TryDecode), Aggregate.State.Fold, Aggregate.State.Initial, Aggregate.State.IsOrigin, @@ -50,7 +50,7 @@ public Aggregate.Service CreateAggregateService() // _context.Resolve( // Thing.Event.Category, // _handlerLog, -// EquinoxCodec.Create(Thing.Event.Encode, Thing.Event.TryDecode), +// EquinoxCodec.Create(Thing.Event.Encode, Thing.Event.TryDecode), // Thing.Fold.Fold, // Thing.Fold.Initial, // Thing.Fold.IsOrigin, diff --git a/equinox-web-csharp/Web/Web.csproj b/equinox-web-csharp/Web/Web.csproj index e80453f46..6c725aedd 100755 --- a/equinox-web-csharp/Web/Web.csproj +++ b/equinox-web-csharp/Web/Web.csproj @@ -6,7 +6,6 @@ enable 5 true - todos From 75eef4f0ca25ae8d767515d1541ec8feaa38e27a Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 8 Apr 2026 09:14:59 +0100 Subject: [PATCH 3/3] Update to .NET/F# 10 --- equinox-web/.vscode/settings.json | 3 + equinox-web/Domain/Infrastructure.fs | 2 + equinox-web/Domain/Todo.fs | 8 +- .../Web/Controllers/TodosController.fs | 24 +++- equinox-web/Web/Infrastructure.fs | 105 ++++++++++++++++ equinox-web/Web/Program.fs | 118 +++++++++++++++--- .../Web/Properties/launchSettings.json | 14 --- equinox-web/Web/Web.fsproj | 10 +- 8 files changed, 237 insertions(+), 47 deletions(-) create mode 100644 equinox-web/.vscode/settings.json create mode 100644 equinox-web/Web/Infrastructure.fs diff --git a/equinox-web/.vscode/settings.json b/equinox-web/.vscode/settings.json new file mode 100644 index 000000000..7dcfb8d0b --- /dev/null +++ b/equinox-web/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.inlayHints.enabled": "offUnlessPressed" +} \ No newline at end of file diff --git a/equinox-web/Domain/Infrastructure.fs b/equinox-web/Domain/Infrastructure.fs index ed05a23e5..a696ac116 100644 --- a/equinox-web/Domain/Infrastructure.fs +++ b/equinox-web/Domain/Infrastructure.fs @@ -4,10 +4,12 @@ open FSharp.UMX // see https://github.com/fsprojects/FSharp.UMX - % operator and open System module Guid = + let inline tryParse (x: string) = match Guid.TryParse x with true, x -> Some x | false, _ -> None let inline toStringN (x: Guid) = x.ToString "N" /// ClientId strongly typed id; represented internally as a Guid; not used for storage so rendering is not significant type ClientId = Guid and [] clientId module ClientId = + let ofGuid (value: Guid): ClientId = %value let toString (value: ClientId): string = Guid.toStringN %value diff --git a/equinox-web/Domain/Todo.fs b/equinox-web/Domain/Todo.fs index bab250ec5..3e60acf24 100644 --- a/equinox-web/Domain/Todo.fs +++ b/equinox-web/Domain/Todo.fs @@ -35,8 +35,8 @@ module Fold = /// Compute State change implied by a given Event let evolve state = function | Events.Added item -> { items = item :: state.items; nextId = state.nextId + 1 } - | Events.Updated value -> { state with items = state.items |> List.map (function { id = id } when id = value.id -> value | item -> item) } - | Events.Deleted e -> { state with items = state.items |> List.filter (fun x -> x.id <> e.id) } + | Events.Updated e -> { state with items = state.items |> List.map (fun x -> if x.id = e.id then e else x) } + | Events.Deleted e -> { state with items = state.items |> List.filter (fun x -> x.id <> e.id) } | Events.Cleared e -> { nextId = e.nextId; items = [] } | Events.Snapshotted s -> { nextId = s.nextId; items = List.ofArray s.items } /// Folds a set of events from the store into a given `state` @@ -56,7 +56,7 @@ let decideAdd value (state: Fold.State) = let decideUpdate itemId value (state: Fold.State) = [| let proposed = mkItem itemId value - match state.items |> List.tryFind (function { id = id } -> id = itemId) with + match state.items |> List.tryFind (fun x -> x.id = itemId) with | Some current when current <> proposed -> Events.Updated proposed | _ -> () |] @@ -64,7 +64,7 @@ let decideDelete id (state: Fold.State) = [| if state.items |> List.exists (fun x -> x.id = id) then Events.Deleted { id = id } |] let decideClear (state: Fold.State) = [| - if state.items |> List.isEmpty |> not then Events.Cleared { nextId = state.nextId } |] + if state.items <> [] then Events.Cleared { nextId = state.nextId } |] /// A single Item in the Todo List type View = { id: int; order: int; title: string; completed: bool } diff --git a/equinox-web/Web/Controllers/TodosController.fs b/equinox-web/Web/Controllers/TodosController.fs index f9c84a731..f26047cdb 100644 --- a/equinox-web/Web/Controllers/TodosController.fs +++ b/equinox-web/Web/Controllers/TodosController.fs @@ -1,9 +1,25 @@ namespace TodoBackendTemplate.Controllers open Microsoft.AspNetCore.Mvc +open System open TodoBackendTemplate -type FromClientIdHeaderAttribute() = inherit FromHeaderAttribute(Name="COMPLETELY_INSECURE_CLIENT_ID") +type ClientIdModelBinder() = + interface ModelBinding.IModelBinder with + member _.BindModelAsync bindingContext = task { + bindingContext.Result <- + bindingContext.HttpContext.Request.Headers["COMPLETELY_INSECURE_CLIENT_ID"] + |> Seq.tryHead + |> Option.bind Guid.tryParse + |> Option.defaultValue Guid.Empty + |> ModelBinding.ModelBindingResult.Success + } + +/// Binds ClientId from the COMPLETELY_INSECURE_CLIENT_ID header, defaulting to Guid.Empty when absent +[] +type FromClientIdHeaderAttribute() = + inherit ModelBinderAttribute(typeof) + override _.BindingSource = ModelBinding.BindingSource.Header type TodoView = { id: int @@ -16,7 +32,7 @@ type GetByIdArgsTemplate = { id: int } // To run: // & dotnet run -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._ +// # NB Jet does not own, control or audit https://todobackend.com; it is a third party site; please satisfy yourself that this is a safe thing to 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 @@ -39,7 +55,7 @@ type TodosController(service: Todo.Service) = [] member this.Get([]clientId: ClientId, id): Async = async { let! x = service.TryGet(clientId, id) - return match x with None -> this.NotFound() :> _ | Some x -> ObjectResult(this.WithUri x) :> _ + return match x with None -> this.NotFound() | Some x -> ObjectResult(this.WithUri x) } [] @@ -60,4 +76,4 @@ type TodosController(service: Todo.Service) = [] member _.DeleteAll([]clientId: ClientId): Async = - service.Clear(clientId) + service.Clear clientId diff --git a/equinox-web/Web/Infrastructure.fs b/equinox-web/Web/Infrastructure.fs new file mode 100644 index 000000000..5e98076d3 --- /dev/null +++ b/equinox-web/Web/Infrastructure.fs @@ -0,0 +1,105 @@ +namespace TodoBackendTemplate.Web + +open Microsoft.Extensions.DependencyInjection +open System +open TodoBackendTemplate + +/// Equinox store bindings +module Storage = + + /// Specifies the store to be used, together with any relevant custom parameters + [] + type Config = +//#if (memoryStore || (!cosmos && !dynamo && !eventStore)) + | Memory +//#endif +//#if eventStore + | Esdb of connectionString: string * cacheMb: int +//#endif +//#if cosmos + | Cosmos of mode: Microsoft.Azure.Cosmos.ConnectionMode * connectionStringWithUriAndKey: string * database: string * container: string * cacheMb: int +//#endif +//#if dynamo + | Dynamo of region: string * tableName: string * cacheMb: int +//#endif + +//#if (memoryStore || (!cosmos && !dynamo && !eventStore)) + /// MemoryStore 'wiring', uses Equinox.MemoryStore nuget package + module private Memory = + open Equinox.MemoryStore + let connect () = + VolatileStore() + +//#endif +//#if eventStore + /// EventStore wiring, uses Equinox.EventStoreDb nuget package + module private ES = + open Equinox.EventStoreDb + let connect connectionString = + let c = EventStoreConnector(reqTimeout=TimeSpan.FromSeconds 5.(*, reqRetries = 1*)) + let conn = c.Establish("Twin", Discovery.ConnectionString connectionString, ConnectionStrategy.ClusterTwinPreferSlaveReads) + EventStoreContext(conn, batchSize = 500) + +//#endif +//#if cosmos + /// CosmosDb wiring, uses Equinox.CosmosStore nuget package + module private Cosmos = + let connect (mode, discovery, databaseId, containerId) (maxRetryForThrottling, maxRetryWait) = + let conn = Equinox.CosmosStore.CosmosStoreConnector(discovery, maxRetryForThrottling, maxRetryWait, mode = mode) + let client = conn.Connect(databaseId, [| containerId |]) |> Async.RunSynchronously + Equinox.CosmosStore.CosmosStoreContext(client, databaseId, containerId, tipMaxEvents = 256) + +//#endif +//#if dynamo + /// DynamoDB wiring, uses Equinox.DynamoStore nuget package + module private Dynamo = + open Equinox.DynamoStore + let connect (region, table) (timeout, retries) = + let c = DynamoStoreConnector(region, timeout, retries).CreateDynamoStoreClient() + DynamoStoreContext.Establish(c, table) |> Async.RunSynchronously + +//#endif + /// Creates and/or connects to a specific store as dictated by the specified config + let connect = function +//#if (memoryStore || (!cosmos && !dynamo && !eventStore)) + | Config.Memory -> + let store = Memory.connect() + Store.Config.Memory store +//#endif +//#if eventStore + | Config.Esdb (connectionString, cache) -> + let cache = Equinox.Cache("ES", sizeMb = cache) + let conn = ES.connect connectionString + Store.Config.Esdb (conn, cache) +//#endif +//#if cosmos + | Config.Cosmos (mode, connectionString, database, container, cache) -> + let cache = Equinox.Cache("Cosmos", sizeMb = cache) + let retriesOn429Throttling = 1 // Number of retries before failing processing when provisioned RU/s limit in CosmosDb is breached + let timeout = TimeSpan.FromSeconds 5. // Timeout applied per request to CosmosDb, including retry attempts + let context = Cosmos.connect (mode, Equinox.CosmosStore.Discovery.ConnectionString connectionString, database, container) (retriesOn429Throttling, timeout) + Store.Config.Cosmos (context, cache) +//#endif +//#if dynamo + | Config.Dynamo (region, table, cache) -> + let cache = Equinox.Cache("Dynamo", sizeMb = cache) + let retries = 1 // Number of retries before failing processing when provisioned RU/s limit in CosmosDb is breached + let timeout = TimeSpan.FromSeconds 5. // Timeout applied per request, including retry attempts + let context = Dynamo.connect (region, table) (timeout, retries) + Store.Config.Dynamo (context, cache) +//#endif + +/// Dependency Injection wiring for services using Equinox +module Services = + + /// Registers the Equinox Store, Stream Resolver, Service Builder and the Service + let register (services: IServiceCollection, storage) = + let store = Storage.connect storage +//#if todos + services.AddSingleton(Todo.Factory.create store) |> ignore +//#endif +//#if aggregate + services.AddSingleton(Aggregate.Factory.create store) |> ignore +//#else + //services.AddSingleton(Thing.Config.create store) |> ignore +//#endif \ No newline at end of file diff --git a/equinox-web/Web/Program.fs b/equinox-web/Web/Program.fs index 99441af4d..0a30d168c 100644 --- a/equinox-web/Web/Program.fs +++ b/equinox-web/Web/Program.fs @@ -1,38 +1,116 @@ module TodoBackendTemplate.Web.Program -open Microsoft.AspNetCore -open Microsoft.AspNetCore.Hosting +open Microsoft.AspNetCore.Builder +open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Hosting +open Prometheus open Serilog -[] -type Logging() = +let [] AppName = "TodoBackendTemplate" - [] - static member Configure(c: LoggerConfiguration, appName) = - let customTags = ["app", appName] - c +[] +let main argv = + Log.Logger <- + LoggerConfiguration() .MinimumLevel.Debug() .MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning) #if cosmos - .WriteTo.Sink(Equinox.CosmosStore.Prometheus.LogSink(customTags)) + .WriteTo.Sink(Equinox.CosmosStore.Prometheus.LogSink(["app", AppName])) #endif #if dynamo - .WriteTo.Sink(Equinox.DynamoStore.Prometheus.LogSink(customTags)) + .WriteTo.Sink(Equinox.DynamoStore.Prometheus.LogSink(["app", AppName])) #endif + .Enrich.WithProperty("app", AppName) .Enrich.FromLogContext() .WriteTo.Console() + .CreateLogger() + try try + let builder = WebApplication.CreateBuilder argv + builder.Host.UseSerilog() |> ignore -let createWebHostBuilder args: IWebHostBuilder = - WebHost - .CreateDefaultBuilder(args) - .UseSerilog() - .UseStartup() + builder.Services + .AddMvc() + .AddJsonOptions(fun options -> + FsCodec.SystemTextJson.Options.Default.Converters + |> Seq.iter options.JsonSerializerOptions.Converters.Add + ) |> ignore -let [] AppName = "TodoBackendTemplate" +//#if todos + builder.Services.AddCors() |> ignore +//#endif -[] -let main argv = - try Log.Logger <- LoggerConfiguration().Configure(AppName).CreateLogger() - try createWebHostBuilder(argv).Build().Run(); 0 +//#if (cosmos || eventStore || dynamo) + // 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 + let cacheMb = 50 + +//#endif +#if eventStore + // EVENTSTORE: See https://github.com/jet/equinox/blob/master/docker-compose.yml for the associated docker-compose configuration + + let storage = Storage.Config.Esdb ("esdb://admin:changeit@localhost:2111,localhost:2112,localhost:2113?tls=true&tlsVerifyCert=false", cacheMb) + +#endif +#if cosmos + // AZURE COSMOSDB: Events are stored as items in a CosmosDb Container + // Provisioning Steps: + // 1) Set the 3x environment variables EQUINOX_COSMOS_CONNECTION, EQUINOX_COSMOS_DATABASE, EQUINOX_COSMOS_CONTAINER + // 2) Provision a container using the following command sequence: + // dotnet tool install -g Equinox.Tool + // eqx init -ru 1000 cosmos -s $env:EQUINOX_COSMOS_CONNECTION -d $env:EQUINOX_COSMOS_DATABASE -c $env:EQUINOX_COSMOS_CONTAINER + let storage = + let connectionVar, databaseVar, containerVar = "EQUINOX_COSMOS_CONNECTION", "EQUINOX_COSMOS_DATABASE", "EQUINOX_COSMOS_CONTAINER" + let read key = Environment.GetEnvironmentVariable key |> Option.ofObj + match read connectionVar, read databaseVar, read containerVar with + | Some connection, Some database, Some container -> + let connMode = Microsoft.Azure.Cosmos.ConnectionMode.Direct // Best perf - select one of the others iff using .NETCore on linux or encounter firewall issues + Store.Config.Cosmos (connMode, connection, database, container, cacheMb) +//#if cosmosSimulator + | None, Some database, Some container -> + // alternately, you can feed in this connection string in as a parameter externally and remove this special casing + let wellKnownConnectionStringForCosmosDbSimulator = + "AccountEndpoint=https://localhost:8081;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==;" + Storage.Config.Cosmos (Microsoft.Azure.Cosmos.ConnectionMode.Direct, wellKnownConnectionStringForCosmosDbSimulator, database, container, cacheMb) +//#endif + | _ -> failwith $"Event Storage subsystem requires the following Environment Variables to be specified: %s{connectionVar}, %s{databaseVar}, %s{containerVar}" + +#endif +#if dynamo + let storage = + let regionVar, tableVar = "EQUINOX_DYNAMO_REGION", "EQUINOX_DYNAMO_TABLE" + let read key = Environment.GetEnvironmentVariable key |> Option.ofObj + match read regionVar, read tableVar with + | Some region, Some table -> + Storage.Config.Dynamo (region, table, cacheMb) + | _ -> failwith $"Event Storage subsystem requires the following Environment Variables to be specified: %s{regionVar}, %s{tableVar}" + +#endif +#if (memoryStore && !cosmos && !dynamo && !eventStore) + let storage = Storage.Config.Memory + +#endif +//#if (!memoryStore && !cosmos && !dynamo && !eventStore) + let storage = Storage.Config.Memory + +//#endif + Services.register (builder.Services, storage) + + let app = builder.Build() + + if app.Environment.IsDevelopment() then app.UseDeveloperExceptionPage() |> ignore + else app.UseHsts() |> ignore + + app.UseHttpsRedirection() |> ignore + app.UseSerilogRequestLogging() |> ignore // see https://nblumhardt.com/2019/10/serilog-in-aspnetcore-3/ +//#if todos + // NB Jet does not own, control or audit https://todobackend.com; it is a third party site; please satisfy yourself that this is a safe thing to use in your environment before using it. + app.UseCors(fun x -> x.WithOrigins([|"https://www.todobackend.com"|]).AllowAnyHeader().AllowAnyMethod() |> ignore) |> ignore +//#endif + app.MapControllers() |> ignore + app.MapMetrics() |> ignore // Host /metrics for Prometheus + + app.Run() + 0 with e -> Log.Fatal(e, "Application Startup failed"); 1 finally Log.CloseAndFlush() diff --git a/equinox-web/Web/Properties/launchSettings.json b/equinox-web/Web/Properties/launchSettings.json index 65c585a81..9de60e2f6 100644 --- a/equinox-web/Web/Properties/launchSettings.json +++ b/equinox-web/Web/Properties/launchSettings.json @@ -1,20 +1,6 @@ { - "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": { diff --git a/equinox-web/Web/Web.fsproj b/equinox-web/Web/Web.fsproj index babdf90f1..1eda9c3a0 100644 --- a/equinox-web/Web/Web.fsproj +++ b/equinox-web/Web/Web.fsproj @@ -1,23 +1,23 @@  - net6.0 + net10.0 5 true - + - - - + + +