Remoting

Easily call server-side functions from the client side.

Defining the service

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.

Calling on the client side

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.

  1. In your Blazor startup (Client/Startup.fs), add support for remoting:

    open Bolero.Remoting.Client
    
    type Startup() =
    
        member __.ConfigureServices(services: IServiceCollection) =
            services.AddRemoting()
            |> ignore
    
  2. Retrieve the client-side service in the ProgramComponent by using this.Remote:

    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
    
  3. In update, use the service in Cmds:

    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 }, []
    
    

Defining on the server side

On the server side, Bolero.Remoting is registered as a service and added as ASP.NET Core middleware. There are several ways to do so.

A simple service

Here is how to implement a remote service without any dependencies.

  1. 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
            }
        }
    
  2. In your ASP.NET Core startup (Server/Startup.fs), register the service:

    open Bolero.Remoting.Server
    
    type Startup() =
    
        member this.ConfigureServices(services: IServiceCollection) =
            services.AddRemoting(myService)
            |> ignore
    
  3. In your ASP.NET Core startup, start the remoting middleware:

    type Startup() =
    
        member this.Configure(app: IApplicationBuilder) =
            app.UseRemoting()
                .UseBlazor<Client.Startup>()
            |> ignore
    

    Note that UseRemoting (and any other middleware) must be called before UseBlazor, because UseBlazor unconditionally catches all requests.

Using dependency injection

You might need to use injected dependencies in a remote service: a logger, a database connection, etc. For this, you need a different approach.

  1. 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
                }
            }
    
  2. In your ASP.NET Core startup, register the service by type rather than by instance:

    type Startup() =
    
        member this.ConfigureServices(services: IServiceCollection) =
            services.AddRemoting<MyServiceHandler>()
            |> ignore
    

Using several services

You can of course define several remote services in the same application. Each of them needs to be registered by a separate call to AddRemoting in ConfigureServices. A single call to UseRemoting is enough in Configure.

Authentication and authorization

Introduced in v0.4.

Bolero includes facilities for remote function authentication and authorization. They are based on standard ASP.NET Core functionality.

  • Authentication means signing in, signing out and identifying the current user in remote functions.
  • Authorization means specifying that a given remote function can only be used by authenticated users, optionally with additional criteria such as "is admin".

Authentication

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, use the following:

    app.UseAuthentication()
        // .OtherMethods()...
    |> 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. To get access to it, wrap the function in Remote.withContext.

let myService =
    {
        functionWithoutAuth = fun arg -> async {
            return arg + 1
        }

        functionWithAuth = Remote.withContext <| fun http arg -> async {
            // `http` has type HttpContext.
            return arg + 1
        }
    }

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 =
    {
        signIn = Remote.withContext <| fun http username -> async {
            if password = "password" then // Replace this with a proper check!
                return! http.AsyncSignIn(username)
        }
        signOut = Remote.withContext <| fun http () -> async {
            return! http.AsyncSignOut()
        }
        getUsername = Remote.withContext <| fun http () -> async {
            return! http.TryUsername()
        }
    }

Authorization

Authorization also uses standard ASP.NET Core features. It is enabled in the startup class's ConfigureServices method:

member this.ConfigureServices(services: IServiceCollection) =
    services
        .AddAuthorization()
        //.OtherMethods()...
    |> ignore

You can then mark a remote function as authorized, ie. callable only by authenticated users, using the function Remote.authorize. This function has the same signature as Remote.withContext, but additionally indicates that the function is authorized.

type UserDataService =
    {
        getSecretData : unit -> Async<string>
    }

let userDataService =
    {
        getSecretData = Remote.authorize <| fun http () -> async {
            // User is guaranteed to be authenticated here.
            return "Secret user data!"
        }
    }

You can use more fine-tuned authorization policies using Remote.authorizeWith. This function takes a list of ASP.NET Core AuthorizeAttributes 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 =
    {
        signIn = Remote.withContext <| fun http (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! http.AsyncSignIn(username, claims = claims)
        }

        // Only an admin can call this function
        getSecretData = Remote.authorizeWith [AuthorizeAttribute(Role = "admin")] <| fun http () -> async {
            return "Super secret data for admin eyes only!"
        }
    }

From the client side

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.ofRemote. 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 RemoteResponse<'resp>:

type RemoteResponse<'resp> =
    | Success of 'resp
    | Unauthorized

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 RemoteResponse<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 (Success data) ->
        { model with secretData = Some data }, []
    | GotSecretData Unauthorized ->
        // 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.