dotnet-websharper / core

WebSharper - Full-stack, functional, reactive web apps and microservices in F# and C#
https://websharper.com
Apache License 2.0
593 stars 52 forks source link

Change how RPC endpoints are exposed #1336

Closed granicz closed 1 year ago

granicz commented 1 year ago

Currently, you can define an RPC function in one of two ways:

1) Placing it in a module and marking it with the Rpc/Remote attribute:

```fsharp
module Server =
    [<Rpc>]
    let AllUsers() = async { ... }
```

No registering of the function is necessary, and calls to `Server.AllUsers` from client-side code are turned into remote procedure calls automatically.

2) Defining a class with instance members to expose them as RPs. For instance:

```fsharp
type UserServices =
    member this.AllUsers() = async { ... }
    ...
```

You can define any number of such RPC/service types, but you need to register each in your web server:

```fsharp
type Startup() =
    member this.ConfigureServices(services: IServiceCollection) =
        services.AddWebSharperRemoting<UserServices>()
        |> ignore
```

Calling on the client happens via the `Remote` function (with `WebSharper.JavaScript` opened):

```fsharp
[<JavaScript>]
let FetchServerData() = async {
    let! users = Remote<UserServices>.AllUsers()
    ...
}
```

In either case, RPs are exposed at the root of the web application and they require a custom communication protocol to call - which the generated client-side code automatically provides (designed to make calls more secure and type-safe.) This special handling includes a custom request header (x-websharper-rpc), a hash of the RP that includes "version info" to make sure there is no API mismatch, arguments serialized to JSON, and upon return, activating the response as a JS value.

However, this is sub-optimal from several angles, including:

1) Non-WS generated clients (say, ordinary JS code) have a hard time calling these RPs, as the communication protocol is nearly impossible to mimic manually. 2) RPC responses are "asymmetrical" compared to other client-side values, they contain additional metadata that only makes sense to a WebSharper client. 3) The location of the RP is not reflected in the URL, but in the request header - which is counter-intuitive from a service-oriented viewpoint.

Therefore, this ticket is a proposal to change the current RPC communication protocol to one, where:

1) There is no metadata in responses (see #930 #922 ). 2) RPs are exposed at a predictable location, which can also be customized easily, for instance /{module-name}/{function-name} /{type-name}/{function-name}. 3) RPs that have no arguments (their signature is unit -> ....) are mapped to simple GET endpoints, and all others use POST to send their arguments via a single JSON payload (can be extended later). 4) No custom request headers are necessary.

This ticket should be coupled with #793 to maximize utility.

Jand42 commented 1 year ago

@granicz I have created a proposal for authentication change #1338

About this ticket I just have one thing to change: all RPC calls should be POST by default to not break HTTP semantics. GETs should be non-side-effecting, which we can't know from just the RPC method not having arguments. We can add a way to annotate method to be a "GET", for example adding an argument to attribute: [<Remote(isGet = true)>].

We could also allow changing path on the Remote attribute itself for ese of use: `[<Remote("/myApi/doStuff")>].

rbauduin commented 1 year ago

This looks very interesting. I have written some code to use ASP.Net's endpoint routing in a WebSharper app and handling RPCs is sub optimal due to the fact the RPC call is not posted to a predictable location. I think this change would allow me to have much finer grained control on RPC access using only the Authorize annotation, looking forward to it!

granicz commented 1 year ago

@rbauduin Just making sure things fall into the right place, can you add an example here?

rbauduin commented 1 year ago

@granicz Currently, I can annotate WS Endpoints like this:

[<EndPoint "/admin";Authorize(Policy="Admin");Wildcard>] Admin of args:list<string>

This requires a user to have admin rights to access this endpoint.

I imagine something similar could be done for Rpc. Just making this up, but if I could then do something like

[<Rpc "/path/to/call";Authorize(Policy="SuperAdmin")]

and update the AspNet endpoint routing to require the policy "SuperAdmin" for all (POST) requests to "/path/to/call".

As WS Endpoints are defined in a DU type, it's easy to retrieve and handle all the cases. The trick will be to retrieve all Rpc annotations and add the required authorization checks for the requests at these locations. I don't know yet how to do it, and hope it would be possible with reflection without too much fuss. Let me know if you need more info regarding my use case.