Easily call server-side functions from the client side.
A set of server-side functions is defined as a record called a remote service. Each function is a field in this record, and must take one argument and return Async<_>
. If you need to pass several arguments to a server-side function, use a tuple.
The record should implement IRemoteService
to define the URL for its functions. Each function is served at the path {service.BasePath}/{fieldName}
.
For example, here is the definition of a service for a simple key-value pair storage:
open Bolero.Remoting
type MyService =
{
getEntry : string -> Async<string option> // Served at /myService/getEntry
setEntry : string * string -> Async<unit> // Served at /myService/setEntry
deleteEntry : string -> Async<unit> // Served at /myService/deleteEntry
}
interface IRemoteService with
member this.BasePath = "/myService"
Remote calls are POST
requests to the function's URL. Arguments and return values are automatically serialized to JSON.
On the client side, you will typically want to call these functions in the update
of the Elmish app. See the Elmish documentation to learn how to run commands in update
.
In your Blazor startup (Client/Startup.fs
), add support for remoting:
open Bolero.Remoting.Client
type Startup() =
member __.ConfigureServices(services: IServiceCollection) =
services.AddBoleroRemoting()
|> ignore
Retrieve the client-side service in the ProgramComponent
by using this.Remote
:
open Bolero.Remoting
type App() =
inherit ProgramComponent<Model, Message>()
override this.Program =
// Retrieve the service
let myService = this.Remote<MyService>()
// Pass it to `update`
Program.mkProgram (fun _ -> initModel, []) (update myService) view
In update
, use the service in Cmd
s:
type Model =
{ latestRetrievedEntry : string * string
latestError : exn option }
type Message =
// Trigger a `getEntry` request
| GetEntry of key: string
// Received response of a `getEntry` request
| GotEntry of key: string * value: string
// A request threw an error
| Error of exn
let update myService message model =
match message with
| GetEntry key ->
model,
Cmd.ofAsync
myService.getEntry key // async call and argument
(fun value -> GotEntry(key, value)) // message to dispatch on response
Error // message to dispatch on error
| GotEntry(key, value) ->
{ model with latestRetrievedEntry = (key, value) }, []
| Error exn ->
{ model with latestError = Some exn }, []
On the server side, Bolero.Remoting is registered as a service and added as ASP.NET Core routing endpoint. There are several ways to do so.
Note: Until version 0.21, Bolero.Remoting was registered as an ASP.NET Core middleware. That usage still works, but it is considered obsolete, and it is advised to switch to endpoint routing as explained in the upgrade guide.
Here is how to implement a remote service without any dependencies.
Implement the service as a value:
// A simple global map as storage.
// A real-world app would probably use a database instead.
let mutable storage = Map.empty
let myService =
{
getEntry = fun key -> async {
return Map.tryFind key
}
setEntry = fun (key, value) -> async {
storage <- Map.add key value storage
}
deleteEntry = fun key -> async {
storage <- Map.remove key storage
}
}
In your ASP.NET Core startup (Server/Startup.fs
), register the service:
open Bolero.Remoting.Server
type Startup() =
member this.ConfigureServices(services: IServiceCollection) =
services.AddBoleroRemoting(myService)
|> ignore
In your ASP.NET Core startup, register the remoting endpoint:
type Startup() =
member this.Configure(app: IApplicationBuilder) =
app.UseRouting()
// .OtherMethods()...
.UseEndpoints(fun endpoints ->
endpoints.MapBoleroRemoting() |> ignore
// other endpoints...
)
|> ignore
You might need to use injected dependencies in a remote service: a logger, a database connection, etc. For this, you need a different approach.
Implement the service as a class inheriting from RemoteHandler
. Dependencies can be injected from the constructor.
type MyServiceHandler(log: ILogger<MyServiceHandler>) =
inherit RemoteHandler<MyService>()
let mutable storage = Map.empty
override this.Handler =
{
getEntry = fun key -> async {
log.LogInformation("Retrieving {0}", key)
return Map.tryFind key
}
setEntry = fun (key, value) -> async {
log.LogInformation("Setting {0} to {1}", key, value)
storage <- Map.add key value storage
}
deleteEntry = fun key -> async {
log.LogInformation("Deleting {0}", key)
storage <- Map.remove key storage
}
}
In your ASP.NET Core startup, register the service by type rather than by instance:
type Startup() =
member this.ConfigureServices(services: IServiceCollection) =
services.AddBoleroRemoting<MyServiceHandler>()
|> ignore
In your ASP.NET Core startup, register the remoting endpoint, just like for a simple service.
Introduced in v0.8.
Bolero remoting provides a value of type IRemoteContext
for multiple purposes. For example, its HttpContext
property gives access to the ASP.NET Core Microsoft.AspNetCore.Http.HttpContext
of the request.
Here is how to obtain an IRemoteContext
:
If you use dependency injection, then simply inject IRemoteContext
into the constructor:
type MyServiceHandler(ctx: IRemoteContext) =
// ...
If you are not using dependency injection, you can replace your handler record value with a function taking IRemoteContext
as argument and returning a record.
You can of course define several remote services in the same application. Each of them needs to be registered by a separate call to AddBoleroRemoting
in ConfigureServices
. A single call to MapBoleroRemoting
is enough in Configure
.
Introduced in v0.4.
This has changed significantly in v0.8; see the old documentation for authentication and authorization in versions 0.4 through 0.7.
Bolero includes facilities for remote function authentication and authorization. They are based on standard ASP.NET Core functionality.
Authentication is done using standard ASP.NET Core authentication features. Enabling it is therefore done like a usual ASP.NET Core application. Here is an example setup for the server-side Startup.fs
using cookie authentication:
In ConfigureServices
, use the following:
services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie()
.Services
//.OtherMethods()...
|> ignore
In Configure
, call UseAuthentication
before other methods:
app.UseAuthentication()
.UseRouting()
.UseEndpoints(...)
|> ignore
To learn more about ASP.NET Core authentication, you can check the official documentation here.
Authentication in a remote function uses the Microsoft.AspNetCore.Http.HttpContext
provided by IRemoteContext
(see above).
HttpContext
has a lot of methods and properties. The extension methods added by Bolero and relevant for authentication are:
AsyncSignIn()
is used to sign the user in. It takes a username: string
argument, and a number of optional arguments:
persistFor: TimeSpan
decides for how long the signin lasts. If unset, the signin lasts for the duration of the user's browser session.claims: seq<Claim>
adds identity claims to the user, in addition to the Name
claim that is created automatically for the username. Learn more about identity claims here. You can see it in used below when discussing Remote.authorizeWith
.properties: AuthenticationProperties
adds authentication properties. IsPersistent
and ExpiresUTC
are overridden by persistsFor
if that is used.authenticationType: string
sets the authentication type for the identity principal. The default is "Bolero.Remoting"
.AsyncSignOut()
is used to sign the user out. It takes a single optional argument, properties: AuthenticationProperties
.TryUsername()
retrieves the current user's username, as a string option
.TryIdentity()
retrieves the current user's ASP.Net Core identity, as a ClaimsIdentity option
.For example, the following remote service implements simple login, logout and username retrieval:
type LoginService =
{
signIn : string -> Async<unit>
signOut : unit -> Async<unit>
getUsername : unit -> Async<string option>
}
let loginService (ctx: IRemoteContext) =
{
signIn = fun username -> async {
if password = "password" then // Replace this with a proper check!
return! ctx.HttpContext.AsyncSignIn(username)
}
signOut = fun () -> async {
return! ctx.HttpContext.AsyncSignOut()
}
getUsername = fun () -> async {
return! ctx.HttpContext.TryUsername()
}
}
Authorization also uses standard ASP.NET Core features. It is enabled by calling AddAuthorization
in the startup class's ConfigureServices
method:
member this.ConfigureServices(services: IServiceCollection) =
services
.AddAuthorization()
//.OtherMethods()...
|> ignore
and UseAuthorization
in Configure
, after UseRouting
and before UseEndpoints
:
app.UseAuthentication()
.UseRouting()
.UseAuthorization()
.UseEndpoints(...)
|> ignore
You can then mark a remote function as authorized, ie. callable only by authenticated users, by wrapping it in a call to the method Authorize
on IRemoteContext.
type UserDataService =
{
getSecretData : unit -> Async<string>
}
let userDataService (ctx: IRemoteContext) =
{
getSecretData = ctx.Authorize <| fun () -> async {
// User is guaranteed to be authenticated here.
return "Secret user data!"
}
}
You can use more fine-tuned authorization policies using AuthorizeWith
. This method takes a list of ASP.NET Core AuthorizeAttribute
s that specifies the authorization policy for this function. The following example can only be called by a user who was signed in as admin:
type UserDataService =
{
signIn : string * string -> Async<string>
getSecretData : unit -> Async<string>
}
let userDataService (ctx: IRemoteContext) =
{
signIn = fun (username, password) -> async {
if password = "password" then
// If the user is "administrator", add the "admin" role
let claims =
match username with
| "administrator" -> [Claim(ClaimTypes.Role, "admin")]
| _ -> []
return! ctx.HttpContext.AsyncSignIn(username, claims = claims)
}
// Only an admin can call this function
getSecretData = ctx.AuthorizeWith [AuthorizeAttribute(Roles = "admin")] <| fun () -> async {
return "Super secret data for admin eyes only!"
}
}
You can call an authorized function from the client side with the standard Cmd.ofAsync
. If the user is not authorized, then the call will return an error with the exception RemoteUnauthorizedException
.
type Model =
{ secretData : string option
latestError : exn option }
type Message =
// Trigger a `getSecretData` request
| GetSecretData
// Received response of a `getSecretData` request
| GotSecretData of data: string
// A request threw an error or was unauthorized
| Error of exn
let update myService message model =
match message with
| GetSecretData ->
model,
Cmd.ofAsync myService.getSecretData () GotSecretData Error
| GotSecretData data ->
{ model with secretData = Some data }, []
| Error RemoteUnauthorizedException ->
// Tried to getSecretData, but the user was not signed in
{ model with secretData = None }, []
| Error exn ->
// Another error happened (eg. the server was unavailable)
{ model with latestError = Some exn }, []
This way is particularly convenient if you have several remote functions that need to handle authorization errors the same way (eg by showing a login popup), as they can all use the same Error
message.
Alternatively, you can use the function Cmd.ofAuthorized
. This function is similar to Cmd.ofAsync
, except that it handles both success and unauthorized call with the same message by passing a value of type option<'resp>
. This value is None
if the user is not authorized, or Some
with the returned value if the user is authorized.
This way is more convenient for a remote function that needs to handle authorization errors in a specific way.
type Model =
{ secretData : string option
latestError : exn option }
type Message =
// Trigger a `getSecretData` request
| GetSecretData
// Received response of a `getSecretData` request
| GotSecretData of option<string>
// A request threw an error
| Error of exn
let update myService message model =
match message with
| GetSecretData ->
model,
Cmd.ofRemote myService.getSecretData () GotSecretData Error
| GotSecretData (Some data) ->
{ model with secretData = Some data }, []
| GotSecretData None ->
// Tried to getSecretData, but the user was not signed in
{ model with secretData = None }, []
| Error exn ->
// Another error happened (eg. the server was unavailable)
{ model with latestError = exn }, []
The variant Cmd.performRemote
doesn't take an Error
message and ignores non-authorization-related errors.