Register over HTTP
Use the same account rule behind two ASP.NET Core endpoints. This optional
sample reuses Account.fs / Account.cs from the console project. Jump to the requests.
POST a registration
id comes from the URL; request.Name comes from JSON. The endpoint sends RegisterUser and waits
for the account to appear in the query view before returning:
Shared setup
module Account =
open FCQRS.Common
open FCQRS.FSharp
type RegisterUser = RegisterUser of name: string
type UserRegistered = UserRegistered of name: string
let decide (command: Command<RegisterUser>) (state: string option) =
let (RegisterUser name) = command.CommandDetails
persistIf state.IsNone (UserRegistered(defaultArg state name))
let fold (event: Event<UserRegistered>) (_state: string option) =
let (UserRegistered name) = event.EventDetails
Some name
open System
open FCQRS.Model.Data
open FCQRS.Common
open FCQRS.FSharp
open Account
module UserView =
open System
open System.Collections.Concurrent
open System.Threading
open System.Threading.Tasks
open FCQRS.Common
open Account
// Each account has one immutable registration, so one signal per account is enough.
type UserView() =
let names = ConcurrentDictionary<string, string>()
let signals = ConcurrentDictionary<string, TaskCompletionSource<unit>>()
let ready id = signals.GetOrAdd(id, fun _ ->
TaskCompletionSource<unit>(TaskCreationOptions.RunContinuationsAsynchronously))
// docs:projection
member _.Project(_offset: int64, message: obj) =
match message with
| :? Event<UserRegistered> as stored ->
match stored.Sender with
| Some sender ->
let id = string sender
let (UserRegistered name) = stored.EventDetails
names[id] <- name
ready(id).TrySetResult() |> ignore
| None -> ()
| _ -> ()
// docs:end
member _.TryGet(id: string) = names.TryGetValue(id)
// Also works for a repeated POST: replay or an earlier write may have signaled already.
member _.WaitFor(id: string, cancellationToken: CancellationToken) =
ready(id).Task.WaitAsync(TimeSpan.FromSeconds(30.), cancellationToken)
open System.Threading
open System.Threading.Tasks
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open UserView
[<CLIMutable>]
type RegistrationRequest = { Name: string }
let valid (value: string) = not (String.IsNullOrWhiteSpace(value)) && value.Length <= 255
let endpoints (app: WebApplication) (accounts: AggregateHandle<RegisterUser, UserRegistered>) (users: UserView) =
Fcqrs_250-tutorial_005-http-api.md_page.AccountFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
FCQRS.FSharpIdiomatic-F# functional facade for FCQRS. Gives F# consumers the same one-call ergonomics the C# host-builder (HostExtensions.fs) gives C#, but with F# idioms: records-of-functions for the definitions, typed handles for the results, an explicit wiring pipeline, and plain helpers for saga side effects. It is a *pure addition* that wraps only the existing primitives (IActor.InitializeActor / SagaBuilder.initSimple / Query.init / InitializeSagaStarter / CreateCommandSubscription / Actor.api) and changes nothing in the C# interop layer or the core. open FCQRS.FSharp let api = Fcqrs.actor config loggerFactory (Some (Fcqrs.connect DBType.Sqlite conn)) "Cluster" let documents = Fcqrs.aggregate api { Name="Document"; Initial=...; Decide=...; Fold=... } let slugs = Fcqrs.aggregate api { Name="Slug"; Initial=...; Decide=...; Fold=... } let publication = Fcqrs.saga api (publicationDef documents.Factory slugs.Factory) Fcqrs.wireSagaStarters api [ publication ] let subs = Fcqrs.projection api (Projection.single 0 updateReadModel) // (Projection.multi when you must control which notifications publish) // send a command and await the matching aggregate reply: let! ev = documents.Send (Fcqrs.newCid()) (Fcqrs.aggregateId id) cmd (fun e -> ...)
Fcqrs_250-tutorial_005-http-api.md_page.Account.RegisterUserRegisterUsername: stringstringAn abbreviation for the CLI type . Basic Types
Fcqrs_250-tutorial_005-http-api.md_page.Account.UserRegisteredUserRegistereddecide: Command<RegisterUser> -> string option -> EventAction<UserRegistered>command: Command<RegisterUser>FCQRS.Common.Command`1Represents a command to be processed by an aggregate actor. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam>
state: string optionoptionThe type of optional values. When used from other CLI languages the empty option is the null value. Use the constructors Some and None to create values of this type. Use the values in the Option module to manipulate values of this type, or pattern match against the values directly. 'None' values will appear as the value null to other CLI languages. Instance methods on this type will appear as static methods to other CLI languages due to the use of null as a value representation. Options
CommandDetails: 'CommandDetailsThe specific details or payload of the command.
persistIf: bool -> 'e -> EventAction<'e>Persist the event when `shouldPersist`, else defer it (published and folded but not journalled). The deferred fold should preserve state, because it cannot be replayed. This is the idempotent "emit this verdict, write it only once" shape.
IsNone: boolReturn 'true' if the option is a 'None' value.
defaultArg: 'T option -> 'T -> 'TUsed to specify a default value for an optional argument in the implementation of a function An option representing the argument. The default value of the argument. The argument value. If it is None, the defaultValue is returned. type Vector(x: double, y: double, ?z: double) = let z = defaultArg z 0.0 member this.X = x member this.Y = y member this.Z = z let v1 = Vector(1.0, 2.0) v1.Z // Evaluates to 0. let v2 = Vector(1.0, 2.0, 3.0) v2.Z // Evaluates to 3.0
fold: Event<UserRegistered> -> string option -> string optionevent: Event<UserRegistered>FCQRS.Common.Event`1Represents an event generated by an aggregate actor as a result of processing a command. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam>
_state: string optionEventDetails: 'EventDetailsThe specific details or payload of the event.
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
SystemModelFCQRS.Model.DataFcqrs_250-tutorial_005-http-api.md_page.UserViewCollectionsConcurrentThreadingTasksFcqrs_250-tutorial_005-http-api.md_page.UserView.UserViewnames: ConcurrentDictionary<string,string>``.ctor``: unit -> unitInitializes a new instance of the class that is empty, has the default concurrency level, has the default initial capacity, and uses the default comparer for the key type.
signals: ConcurrentDictionary<string,TaskCompletionSource<unit>>System.Threading.Tasks.TaskCompletionSource`1Represents the producer side of a unbound to a delegate, providing access to the consumer side through the property. The type of the result value associated with this .
unitThe type 'unit', which has only one value "()". This value is special and always uses the representation 'null'. Basic Types
ready: string -> TaskCompletionSource<unit>id: stringGetOrAdd: string * Func<string,TaskCompletionSource<unit>> -> TaskCompletionSource<unit>Adds a key/value pair to the by using the specified function if the key does not already exist. Returns the new value, or the existing value if the key exists. The key of the element to add. The function used to generate a value for the key. or is . The dictionary contains too many elements. The value for the key. This will be either the existing value for the key if the key is already in the dictionary, or the new value if the key was not in the dictionary.
``.ctor``: TaskCreationOptions -> unitCreates a with the specified options. The options to use when creating the underlying . The represent options invalid for use with a .
System.Threading.Tasks.TaskCreationOptionsSpecifies flags that control optional behavior for the creation and execution of tasks.
RunContinuationsAsynchronously: TaskCreationOptionsForces continuations added to the current task to be executed asynchronously. Note that the member is available in the enumeration starting with the .NET Framework 4.6.
_: UserViewProject: UserView -> int64 * obj -> unit_offset: int64int64An abbreviation for the CLI type . Basic Types
message: objobjAn abbreviation for the CLI type . Basic Types
stored: Event<UserRegistered>Sender: AggregateId optionAn optional identifier for the actor that generated the event.
sender: AggregateIdstring: 'T -> stringConverts the argument to a string using ToString. For standard integer and floating point values and any type that implements IFormattable, ToString conversion uses CultureInfo.InvariantCulture. The input value. The converted string. string 'A' // evaluates to "A" string 0xff // evaluates to "255" string -10 // evaluates to "-10"
Item: stringGets or sets the value associated with the specified key. The key of the value to get or set. is . The property is retrieved and does not exist in the collection. The value of the key/value pair at the specified index.
TrySetResult: unit -> boolAttempts to transition the underlying into the state. The result value to bind to this . The was disposed. if the operation was successful; otherwise, .
(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
ignore: 'T -> unitIgnore the passed value. This is often used to throw away results of a computation. The value to ignore. ignore 55555 // Evaluates to ()
NoneThe representation of "No value"
TryGet: UserView -> string -> bool * stringTryGetValue: string * byref<string> -> boolAttempts to get the value associated with the specified key from the . The key of the value to get. When this method returns, contains the object from the that has the specified key, or the default value of the type if the operation failed. is . if the key was found in the ; otherwise, .
WaitFor: UserView -> string * CancellationToken -> Task<unit>cancellationToken: CancellationTokenSystem.Threading.CancellationTokenPropagates notification that operations should be canceled.
WaitAsync: TimeSpan * CancellationToken -> Task<unit>Gets a that will complete when this completes, when the specified timeout expires, or when the specified has cancellation requested. The timeout after which the should be faulted with a if it hasn't otherwise completed. The to monitor for a cancellation request. The representing the asynchronous wait. It may or may not be the same instance as the current instance.
Task: Task<unit>Gets the created by this . Returns the created by this .
System.TimeSpanRepresents a time interval.
FromSeconds: float -> TimeSpanReturns a that represents a specified number of seconds, where the specification is accurate to the nearest millisecond. A number of seconds, accurate to the nearest millisecond. is less than TimeSpan.MinValue or greater than TimeSpan.MaxValue. -or- is . -or- is . is equal to . An object that represents .
MicrosoftAspNetCoreBuilderHttpMicrosoft.FSharp.Core.CLIMutableAttributeAdding this attribute to a record type causes it to be compiled to a CLI representation with a default constructor with property getters and setters. Attributes
Fcqrs_250-tutorial_005-http-api.md_page.RegistrationRequestName: stringvalid: string -> boolvalue: string``not``: bool -> boolNegate a logical value. Not True equals False and not False equals True The value to negate. The result of the negation. not (2 + 2 = 5) // Evaluates to true // not is a function that can be compose with other functions let fileDoesNotExist = System.IO.File.Exists >> not
System.StringRepresents text as a sequence of UTF-16 code units.
IsNullOrWhiteSpace: string -> boolIndicates whether a specified string is , empty, or consists only of white-space characters. The string to test. if the parameter is or , or if consists exclusively of white-space characters.
(&&): bool -> bool -> boolBinary 'and'. When used as a binary operator the right hand value is evaluated only on demand The first value. The second value. The result of the operation.
Length: int(<=): 'T -> 'T -> boolStructural less-than-or-equal comparison The first parameter. The second parameter. The result of the comparison. 5 <= 1 // Evaluates to false 5 <= 5 // Evaluates to true [1; 5] <= [1; 6] // Evaluates to true
endpoints: WebApplication -> AggregateHandle<RegisterUser,UserRegistered> -> UserView -> unitapp: WebApplicationMicrosoft.AspNetCore.Builder.WebApplicationThe web application used to configure the HTTP pipeline, and routes.
accounts: AggregateHandle<RegisterUser,UserRegistered>FCQRS.FSharp.AggregateHandle`2What you get back after registering an aggregate.
users: UserView let register (id: string) (request: RegistrationRequest)
(ct: CancellationToken) = task {
if not (valid id && valid request.Name) then
return Results.BadRequest(
{| error = "ID and name must be non-blank (max 255 characters)." |})
else
try
let! reply =
accounts.Send (Fcqrs.newCid ()) (Fcqrs.aggregateId id)
(RegisterUser request.Name) (fun _ -> true)
|> fun work -> Async.StartAsTask(work, cancellationToken = ct)
do! users.WaitFor(id, ct)
let (UserRegistered name) = reply.EventDetails
let body = {| id = id; name = name |}
return
if reply.Journaled = Some true then
Results.Created($"/accounts/{Uri.EscapeDataString(id)}", body)
else Results.Ok(body)
with :? TimeoutException ->
return Results.Problem(
"Registration may have completed. Retry with the same account ID.",
statusCode = StatusCodes.Status503ServiceUnavailable)
}
app.MapPost("/accounts/{id}",
Func<string, RegistrationRequest, CancellationToken, Task<IResult>>(
fun id request ct -> register id request ct)) |> ignore
register: string -> RegistrationRequest -> CancellationToken -> Task<IResult>id: stringstringAn abbreviation for the CLI type . Basic Types
request: RegistrationRequestFcqrs_250-tutorial_005-http-api.md_page.RegistrationRequestct: CancellationTokenSystem.Threading.CancellationTokenPropagates notification that operations should be canceled.
task: TaskBuilderBuilds a task using computation expression syntax.
``not``: bool -> boolNegate a logical value. Not True equals False and not False equals True The value to negate. The result of the negation. not (2 + 2 = 5) // Evaluates to true // not is a function that can be compose with other functions let fileDoesNotExist = System.IO.File.Exists >> not
valid: string -> bool(&&): bool -> bool -> boolBinary 'and'. When used as a binary operator the right hand value is evaluated only on demand The first value. The second value. The result of the operation.
Name: stringMicrosoft.AspNetCore.Http.ResultsA factory for .
BadRequest: obj -> IResultProduces a response. An error object to be included in the HTTP response body. The created for the response.
error: stringreply: Event<UserRegistered>accounts: AggregateHandle<RegisterUser,UserRegistered>Send: CID -> AggregateId -> 'Command -> ('Event -> bool) -> Async<Event<'Event>>Send a command and await the first matching aggregate event.
FCQRS.FSharp.FcqrsnewCid: unit -> CIDA fresh correlation id (UUID v7).
aggregateId: string -> AggregateIdAn aggregate id from a string (e.g. a document/user key). Any non-blank id works: the shard names entity actors Uri.EscapeDataString(entityId), so characters Akka actor names would reject directly (spaces, %) are escaped before they reach an actor path.
RegisterUser(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
work: Async<Event<UserRegistered>>Microsoft.FSharp.Control.FSharpAsyncHolds static members for creating and manipulating asynchronous computations. See also F# Language Guide - Async Workflows. Async Programming
StartAsTask: Async<'T> * TaskCreationOptions option * CancellationToken option -> Task<'T>Executes a computation in the thread pool. If no cancellation token is provided then the default cancellation token is used. A that will be completed in the corresponding state once the computation terminates (produces the result, throws exception or gets canceled) Starting Async Computations printfn "A" let t = async { printfn "B" do! Async.Sleep(1000) printfn "C" } |> Async.StartAsTask printfn "D" t.Wait() printfn "E" Prints "A", then "D", "B" quickly in any order, then "C", "E" in 1 second.
cancellationTokenusers: UserViewWaitFor: string * CancellationToken -> Task<unit>UserRegisteredname: stringEventDetails: 'EventDetailsThe specific details or payload of the event.
body: {| id: string; name: string |}Journaled: bool optionWhether this envelope's event was journaled, read from the delivery stamp: Some true (a projection event will follow), Some false (a deferred/publish-only reply โ nothing to await), or None (an envelope that never passed through aggregate delivery, e.g. read back from the journal, or produced by a pre-stamp FCQRS).
(=): 'T -> 'T -> boolStructural equality The first parameter. The second parameter. The result of the comparison. 5 = 5 // Evaluates to true 5 = 6 // Evaluates to false [1; 2] = [1; 2] // Evaluates to true (1, 5) = (1, 6) // Evaluates to false
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
Created: string * obj -> IResultProduces a response. The URI at which the content has been created. The value to be included in the HTTP response body. The created for the response.
Ok: obj -> IResultProduces a response. The value to be included in the HTTP response body. The created for the response.
System.TimeoutExceptionThe exception that is thrown when the time allotted for a process or operation has expired.
Problem: string * string * Nullable<int> * string * string * Collections.Generic.KeyValuePair<string,obj> seq -> IResultProduces a response. The value for . The value for . The value for . The value for . The value for . The value for . The created for the response.
statusCodeMicrosoft.AspNetCore.Http.StatusCodesA collection of constants for HTTP status codes. Descriptions for status codes are available from .
Status503ServiceUnavailable: intHTTP status code 503.
app: WebApplicationMapPost: string * Delegate -> RouteHandlerBuilderAdds a to the that matches HTTP POST requests for the specified pattern. The to add the route to. The route pattern. The delegate executed when the endpoint is matched. A that can be used to further customize the endpoint.
System.Func`4Encapsulates a method that has three parameters and returns a value of the type specified by the parameter. The first parameter of the method that this delegate encapsulates. The second parameter of the method that this delegate encapsulates. The third parameter of the method that this delegate encapsulates. The type of the first parameter of the method that this delegate encapsulates. The type of the second parameter of the method that this delegate encapsulates. The type of the third parameter of the method that this delegate encapsulates. The type of the return value of the method that this delegate encapsulates. The return value of the method that this delegate encapsulates.
System.Threading.Tasks.Task`1Represents an asynchronous operation that can return a value. The type of the result produced by this .
Microsoft.AspNetCore.Http.IResultDefines a contract that represents the result of an HTTP endpoint.
ignore: 'T -> unitIgnore the passed value. This is often used to throw away results of a computation. The value to ignore. ignore 55555 // Evaluates to ()
app.MapPost("/accounts/{id}", async Task<IResult> (
string id, RegistrationRequest request,
Handler<RegisterUser, UserRegistered> accounts, CancellationToken ct) =>
{
if (!Valid(id) || !Valid(request.Name))
return Results.BadRequest(new {
error = "ID and name must be non-blank (max 255 characters)."
});
try
{
var reply = await accounts(_ => true, Values.NewCID(),
Values.CreateAggregateId(id), new RegisterUser(request.Name))
.WaitAsync(ct);
await users.WaitFor(id, ct);
var body = new { id, name = reply.EventDetails.Name };
return reply.Journaled?.Value == true
? Results.Created($"/accounts/{Uri.EscapeDataString(id)}", body)
: Results.Ok(body);
}
catch (TimeoutException)
{
return Results.Problem(
"Registration may have completed. Retry with the same account ID.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
});
The first registration returns 201 Created and a Location header. A repeated request returns
200 OK with the saved name. The registration rule still decides what gets persisted.
Run the API
From the repository root, with .NET 10 installed:
dotnet run --project samples/registration-http-fsharp -- --urls http://localhost:5080
dotnet run --project samples/registration-http-csharp -- --urls http://localhost:5080
Wait for Listening on http://localhost:5080. In a second terminal:
curl -i http://localhost:5080/accounts/alice \
-H 'Content-Type: application/json' -d '{"name":"Alice"}'
The response is 201 Created with this body:
{"id":"alice","name":"Alice"}
Query it immediately:
curl http://localhost:5080/accounts/alice
The response has the same body. Repeat the POST with "name":"Bob": it returns 200 OK and Alice.
Change the URL to /accounts/bob to register Bob separately.
GET the query view
The GET endpoint reads the projected names:
let query (id: string) =
if not (valid id) then Results.BadRequest()
else
match users.TryGet(id) with
| true, name -> Results.Ok({| id = id; name = name |})
| _ -> Results.NotFound()
app.MapGet("/accounts/{id}", Func<string, IResult>(fun id -> query id)) |> ignore
query: string -> IResultid: stringstringAn abbreviation for the CLI type . Basic Types
``not``: bool -> boolNegate a logical value. Not True equals False and not False equals True The value to negate. The result of the negation. not (2 + 2 = 5) // Evaluates to true // not is a function that can be compose with other functions let fileDoesNotExist = System.IO.File.Exists >> not
valid: string -> boolMicrosoft.AspNetCore.Http.ResultsA factory for .
BadRequest: obj -> IResultProduces a response. An error object to be included in the HTTP response body. The created for the response.
users: UserViewTryGet: string -> bool * stringname: stringOk: obj -> IResultProduces a response. The value to be included in the HTTP response body. The created for the response.
NotFound: obj -> IResultProduces a response. The value to be included in the HTTP response body. The created for the response.
app: WebApplicationMapGet: string * Delegate -> RouteHandlerBuilderAdds a to the that matches HTTP GET requests for the specified pattern. The to add the route to. The route pattern. The delegate executed when the endpoint is matched. A that can be used to further customize the endpoint.
System.Func`2Encapsulates a method that has one parameter and returns a value of the type specified by the parameter. The parameter of the method that this delegate encapsulates. The type of the parameter of the method that this delegate encapsulates. The type of the return value of the method that this delegate encapsulates. The return value of the method that this delegate encapsulates.
Microsoft.AspNetCore.Http.IResultDefines a contract that represents the result of an HTTP endpoint.
(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
ignore: 'T -> unitIgnore the passed value. This is often used to throw away results of a computation. The value to ignore. ignore 55555 // Evaluates to ()
app.MapGet("/accounts/{id}", (string id) =>
{
if (!Valid(id)) return Results.BadRequest();
return users.TryGet(id, out var name)
? Results.Ok(new { id, name })
: Results.NotFound();
});
UserView.Project stores each saved name and signals waiting requests. Its per-account signal also
handles repeats: the saved event may have been projected earlier or replayed after a restart.
This works because a registration never changes; views with updates need coordination for the particular write.
Stop with Ctrl+C and run again. SQLite keeps events in bin/Debug/net10.0/registration-http.db
inside the HTTP sample folder; the in-memory view rebuilds from those events.
- 400: the ID or name is blank or longer than 255 characters, or the JSON body is invalid.
- 404: this account is absent from the current view. During startup, replay may still be catching up.
- 503: a command or the 30-second projection wait timed out. Registration may have completed; retry with the same ID.
This sample stores profiles. Passwords and login sessions belong to an authentication provider.
Complete source: F# ยท C#. For the HTTP binding rules, see ASP.NET Core parameter binding.