Logo Orsak

Remoting

Orsak.Remoting lets a client, compiled with Fable or running on .NET, call the members of an interface on a server. The interface is the contract, so the effects created from it run on the client unchanged, while the work happens on the server. It is experimental.

The remoting sample is a todo list in the browser, kept on the server, with all of what this page shows.

The contract

An interface describing a side effect, as for any effect, in a project the server and the client share:

type Order = { Customer: string; Lines: OrderLine list; Note: string option }

type PlaceError =
    | EmptyOrder
    | UnknownProduct of string

type IOrderApi =
    abstract Place: Order -> Task<Result<OrderId, PlaceError>>
    abstract History: customer: string -> Task<Order list>

type IOrderApiProvider =
    abstract OrderApi: IOrderApi

module OrderApi =
    let place order = Effect.Create(fun (p: #IOrderApiProvider) -> p.OrderApi.Place order)
    let history customer = Effect.Create(fun (p: #IOrderApiProvider) -> p.OrderApi.History customer)

An effect built from it doesn't know where it runs:

let orderAndReview order = eff {
    let! id = OrderApi.place order
    let! history = OrderApi.history order.Customer
    return id, history
}

The server

Orsak.Remoting.AspNetCore maps each member of the interface to a POST endpoint, at {prefix}/{interface}/{member}, and calls the implementation made for each request:

app.MapRemote<IOrderApi>("/api", [ JsonFormat.Instance ], fun ctx -> ServerOrderApi(environmentOf ctx))
   .RequireAuthorization()

MapRemote returns the endpoints' group, so authorization, CORS and other conventions apply to all of them. The members may return a Task, a ValueTask or an Async, of a value or of none.

The client

[<GenRemote>], from Orsak.Remoting, on the contract's interface has Orsak.Myriad generate its proxy: the contract's project references Orsak.Myriad, as for its other generators (see Generating code).

[<GenRemote>]
type IOrderApi =
    abstract Place: Order -> Task<Result<OrderId, PlaceError>>
    abstract History: customer: string -> Task<Order list>

The client's environment provides the interface with the proxy, which calls the server through a RemoteClient:

let client = RemoteClient("https://shop.example", "/api", JsonFormat.Instance, FetchTransport())

type ClientEnv() =
    let orderApi = OrderApiProxy.create client

    interface IOrderApiProvider with
        member _.OrderApi = orderApi

orderAndReview order |> Effect.run (ClientEnv())

FetchTransport sends with fetch, in the browser and on Node.js; on .NET, HttpClientTransport sends with an HttpClient. The generated proxy makes the codecs of each member once, with its types, as Fable erases generic arguments at run time, and calls them for each call:

module OrderApiProxy =
    let create (client: Orsak.Remoting.RemoteClient) : IOrderApi =
        let place = client.Member("IOrderApi", "Place", [| typeof<Order> |], typeof<Result<OrderId, PlaceError>>)
        let history = client.Member("IOrderApi", "History", [| typeof<string> |], typeof<Order list>)

        { new IOrderApi with
            member _.Place a = place.Task [| box a |]
            member _.History a = history.Task [| box a |] }

So a type the format can't write fails when the proxy is made, naming the type, and MapRemote fails the same way when it maps the endpoints, as the server starts, rather than either failing on a call. A Fable project compiles the generated code as it is: Myriad runs when .NET builds the contract's project, not when Fable compiles it.

Errors

Authorization

Who a caller is, and what they may do, are checked at two places:

On a member

Conventions on the group MapRemote returns apply to every member, and a member's attributes are its endpoint's metadata, so they apply to it alone, as on a controller's action:

type IDocsApi =
    abstract Ping: unit -> Task<string>

#if !FABLE_COMPILER
    [<Authorize(Roles = "editor")>]
#endif
    abstract Publish: id: int -> Task<string>

    abstract Rename: id: int * title: string -> Task<Result<Doc, DocError>>

app.MapRemote<IDocsApi>("/api", [ JsonFormat.Instance ], fun ctx -> DocsApi(envOf ctx))

The attributes are in the package Microsoft.AspNetCore.Authorization, which the contract's project references. Fable leaves out packages not made for it, so a contract it compiles too hides them from it, with #if !FABLE_COMPILER.

In an effect

create makes the implementation for each request, so the server's environment can have the request's user and ASP.NET Core's IAuthorizationService, whose policies, resource-based ones included, an effect asks:

type DocError =
    | Forbidden
    | NotFound of id: int

module Authz =
    /// Fails with the contract's Forbidden unless the user may do to the resource what the policy says.
    let authorize (resource: obj) (policy: string) = eff {
        let! user = Effect.Create(fun (p: #IUserProvider) -> p.User)
        let! authorization = Effect.Create(fun (p: #IAuthorizationProvider) -> p.Authorization)
        let! result = authorization.AuthorizeAsync(user, resource, policy)

        if not result.Succeeded then
            return! Error Forbidden
    }

let rename id title = eff {
    match! Docs.find id with
    | None -> return! Error(NotFound id)
    | Some doc ->
        do! Authz.authorize doc "owner"
        return! Docs.save { doc with Title = title }
}

app.MapRemote<IDocsApi>("/api", [ JsonFormat.Instance ], fun ctx ->
    DocsApi { User = ctx.User; Authorization = ctx.RequestServices.GetRequiredService<IAuthorizationService>(); Docs = docs })

A member that may be refused says so in its type: Rename returns a Result with Forbidden, while a member returning Task<string> can only fail with an exception. Two choices are the app's:

Credentials from the client

A browser sends its cookies with each call to the page's own origin. A token, or cookies to a server elsewhere, are given to FetchTransport:

FetchTransport(
    headers = (fun () -> task { return [ "Authorization", $"Bearer {currentToken ()}" ] }),
    credentials = FetchCredentials.Include
)

The headers are asked for on each request, so a token can be renewed between calls. On .NET, HttpClientTransport sends what its HttpClient does: default headers, or a handler of its own.

The JSON

JsonFormat writes F# types the same way on .NET and Fable, as application/vnd.orsak+json:

Type

JSON

Record

An object of its fields

Union case

Its name without fields, or an object of its name and its field, or an array of its fields

List, array, tuple

An array

Option

Its value, or null

int, float

A number, in the invariant culture

int64, decimal, Guid

A string, which JavaScript's numbers don't round

byte array

A base64 string

The types may be public or not. Maps, sets and dates aren't supported yet, nor an option of an option: Some None would be written as None is.

Other formats

A format is made of two parts:

WireFormat(encoding) puts them together, and JsonFormat is WireFormat(JsonEncoding.Instance). So another format, such as MessagePack or CBOR, is an encoding of a few dozen lines over a library you already use. The server serves the formats it is given, choosing by the request's content type, and answers 415 to a content type it has no format for:

app.MapRemote<IOrderApi>("/api", [ JsonFormat.Instance; WireFormat(messagePack) ], fun ctx -> ServerOrderApi(environmentOf ctx))

An encoding of MessagePack, with MessagePack-CSharp. Its writer and reader are ref structs, which a closure can't hold, so they're passed by reference to functions of a module:

let rec private write (writer: byref<MessagePackWriter>, value: WireValue) =
    match value with
    | WireValue.Nil -> writer.WriteNil()
    | WireValue.Bool b -> writer.Write b
    | WireValue.Int i -> writer.Write i
    | WireValue.Float f -> writer.Write f
    | WireValue.String s -> writer.Write s
    | WireValue.Bytes b -> writer.Write(ReadOnlySpan b)
    | WireValue.Array items ->
        writer.WriteArrayHeader items.Length

        for item in items do
            write (&writer, item)
    | WireValue.Map fields ->
        writer.WriteMapHeader fields.Length

        for name, field in fields do
            writer.Write name
            write (&writer, field)

let rec private read (reader: byref<MessagePackReader>) : WireValue =
    match reader.NextMessagePackType with
    | MessagePackType.Nil ->
        reader.ReadNil() |> ignore
        WireValue.Nil
    | MessagePackType.Boolean -> WireValue.Bool(reader.ReadBoolean())
    | MessagePackType.Integer -> WireValue.Int(reader.ReadInt32())
    | MessagePackType.Float -> WireValue.Float(reader.ReadDouble())
    | MessagePackType.String -> WireValue.String(reader.ReadString())
    | MessagePackType.Binary -> WireValue.Bytes(reader.ReadBytes().Value.ToArray())
    | MessagePackType.Array ->
        let items = ResizeArray()

        for _ in 1 .. reader.ReadArrayHeader() do
            items.Add(read &reader)

        WireValue.Array(List.ofSeq items)
    | MessagePackType.Map ->
        let fields = ResizeArray()

        for _ in 1 .. reader.ReadMapHeader() do
            let name = reader.ReadString()
            fields.Add((name, read &reader))

        WireValue.Map(List.ofSeq fields)
    | other -> failwith $"MessagePack's {other} isn't a WireValue"

let messagePack =
    { new IWireEncoding with
        member _.ContentType = "application/vnd.orsak+msgpack"

        member _.Write value =
            let buffer = ArrayBufferWriter<byte>()
            let mutable writer = MessagePackWriter(buffer)
            write (&writer, value)
            writer.Flush()
            buffer.WrittenSpan.ToArray()

        member _.Read body =
            let mutable reader = MessagePackReader(ReadOnlyMemory body)
            read &reader }

A Fable client needs an encoding that Fable compiles, in plain F# or over a JavaScript library.

An encoding of the server's own

The client and the server needn't use the same encoding of a content type, only ones that read what the other writes. A server may write Orsak.Remoting's JSON with System.Text.Json, say, while a Fable client uses JsonEncoding:

let systemTextJson =
    { new IWireEncoding with
        member _.ContentType = "application/vnd.orsak+json"

        member _.Write value =
            use stream = new MemoryStream()
            use writer = new Utf8JsonWriter(stream)

            let rec write value =
                match value with
                | WireValue.Nil -> writer.WriteNullValue()
                | WireValue.Bool b -> writer.WriteBooleanValue b
                | WireValue.Int i -> writer.WriteNumberValue i
                | WireValue.Float f -> writer.WriteNumberValue f
                | WireValue.String s -> writer.WriteStringValue s
                | WireValue.Bytes b -> writer.WriteBase64StringValue(ReadOnlySpan b)
                | WireValue.Array items ->
                    writer.WriteStartArray()
                    List.iter write items
                    writer.WriteEndArray()
                | WireValue.Map fields ->
                    writer.WriteStartObject()

                    for name, field in fields do
                        writer.WritePropertyName name
                        write field

                    writer.WriteEndObject()

            write value
            writer.Flush()
            stream.ToArray()

        member _.Read body =
            let rec read (e: JsonElement) =
                match e.ValueKind with
                | JsonValueKind.Null -> WireValue.Nil
                | JsonValueKind.True -> WireValue.Bool true
                | JsonValueKind.False -> WireValue.Bool false
                | JsonValueKind.Number ->
                    match e.TryGetInt32() with
                    | true, i -> WireValue.Int i
                    | _ -> WireValue.Float(e.GetDouble())
                | JsonValueKind.String -> WireValue.String(e.GetString())
                | JsonValueKind.Array -> WireValue.Array [ for item in e.EnumerateArray() -> read item ]
                | _ -> WireValue.Map [ for p in e.EnumerateObject() -> p.Name, read p.Value ]

            use document = JsonDocument.Parse(ReadOnlyMemory body)
            read document.RootElement }

The mapping reads leniently: JSON can't tell bytes from a base64 string, nor 2.0 from 2, so it takes either where it expects bytes or a float.

Checking an encoding

Conformance checks an encoding, from a test of any framework, with values of every kind of type the mapping supports. Each check returns its failures, empty if it passes:

[<Fact>]
let ``the MessagePack encoding passes the conformance checks`` () =
    Assert.Empty(Conformance.check messagePack)

[<Fact>]
let ``the server's JSON agrees with the client's`` () =
    Assert.Empty(Conformance.agree JsonEncoding.Instance systemTextJson)

check writes each value and reads it back. agree has each of two encodings read what the other writes, and checks that their content types are the same. Both encodings above are tested this way in Orsak's own tests.

IWireFormat itself, the codecs of a member's arguments and result given their types, is the level below, for a format with a mapping of its own.

type Order = { Customer: string Lines: obj Note: string option }
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
type 'T list = List<'T>
type 'T option = Option<'T>
type PlaceError = | EmptyOrder | UnknownProduct of string
type IOrderApi = abstract History: customer: string -> 'a abstract Place: Order -> 'a
Multiple items
module Result from Microsoft.FSharp.Core

--------------------
type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
type IOrderApiProvider = abstract OrderApi: IOrderApi
val place: order: 'a -> 'b
val order: 'a
val history: customer: 'a -> 'b
val customer: 'a
val orderAndReview: order: 'a -> 'b
val id: x: 'T -> 'T
module OrderApi from remoting
val client: obj
Multiple items
type ClientEnv = interface IOrderApiProvider new: unit -> ClientEnv

--------------------
new: unit -> ClientEnv
val orderApi: IOrderApi
val create: client: 'a -> IOrderApi
val client: 'a
val place: obj
val typeof<'T> : System.Type
val history: obj
val a: Order
val box: value: 'T -> objnull
val a: string
type IDocsApi = abstract Ping: unit -> 'a abstract Publish: id: int -> 'a abstract Rename: id: int * title: string -> 'a
type unit = Unit
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int
type DocError = | Forbidden | NotFound of id: int
val authorize: resource: obj -> policy: string -> 'a
 Fails with the contract's Forbidden unless the user may do to the resource what the policy says.
val resource: obj
type obj = System.Object
val policy: string
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>
union case DocError.Forbidden: DocError
val rename: id: 'a -> title: 'b -> 'c
val id: 'a
val title: 'b
union case Option.None: Option<'T>
union case DocError.NotFound: id: int -> DocError
union case Option.Some: Value: 'T -> Option<'T>
module Authz from remoting
val task: TaskBuilder
val private write: writer: byref<'a> * value: 'b -> unit
val writer: byref<'a>
type byref<'T> = (# "<Common IL Type Omitted>" #)
val value: 'b
val item: 'b
val name: obj
val field: 'b
val private read: reader: byref<'a> -> 'b
val reader: byref<'a>
val ignore: value: 'T -> unit
val items: ResizeArray<'b>
type ResizeArray<'T> = System.Collections.Generic.List<'T>
System.Collections.Generic.List.Add(item: 'b) : unit
Multiple items
module List from Microsoft.FSharp.Collections

--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T member IsEmpty: bool member Item: index: int -> 'T with get ...
val ofSeq: source: 'T seq -> 'T list
val fields: ResizeArray<obj * 'b>
System.Collections.Generic.List.Add(item: obj * 'b) : unit
val other: obj
val failwith: message: string -> 'T
val messagePack: obj
Multiple items
val byte: value: 'T -> byte (requires member op_Explicit)

--------------------
type byte = System.Byte

--------------------
type byte<'Measure> = byte
val systemTextJson: obj
val iter: action: ('T -> unit) -> list: 'T list -> unit

Type something to start searching.