fsbolero / Bolero

Bolero brings Blazor to F# developers with an easy to use Model-View-Update architecture, HTML combinators, hot reloaded templates, type-safe endpoints, advanced routing and remoting capabilities, and more.
https://fsbolero.io
Apache License 2.0
1.06k stars 54 forks source link

Uploading files #66

Open kos59125 opened 5 years ago

kos59125 commented 5 years ago

I'd like to upload a file and process it on server-side.

I may do this to encode a file into string like base64, but can I upload a file not to decode on server-side?

The following codes illustrate what I'd like to do.

This works, but maybe not for large files.

type Message =
   | FileChanged
   | ContentsRead of string
   | (* ... *)

type UploadService =
   {
      Upload : string-> Async<unit>
   }
   interface IRemoteService with (* ... *)

let update remote jsRuntime message model =
   match message with
   | FileChanged ->
      let readFileJs () = jsRuntime .InvokeAsync<string>("readFile") |> Async.AwaitTask
      model, Cmd.ofAsync readFileJs () ContentsRead (* ofError *)
   | ContentsRead(contents) ->
      model, Cmd.ofRemote remote.Upload contents (* ... *)
   | (* ... *)

let view model dispatch = input [
   attr.id "input-file"
   attr.``type`` "file"
   on.change (fun e -> FileChanged |> dispatch)
]
readFile: () => {
   const input = document.getElementById('input-file');
   const reader = new FileReader();
   return new Promise((resolve, reject) => {
      reader.addEventListener('load', () => {
         resolve(reader.result);
      });
      reader.readAsBinaryString(input.files[0]);
   });
}

This is what I'd like to do and doesn't work.

type Message =
   | SetFile of string 
   | (* ... *)

type UploadService =
   {
      Upload : Stream -> Async<unit>
   }
   interface IRemoteService with (* ... *)

let update remote message model =
   match message with
   | SetFile(file) ->
      model, Cmd.ofRemote remote.Upload (File.OpenRead(file)) (* ... *)

Do I need to prepare a non-Bolero endpoint and call it from JS directly?

Tarmil commented 5 years ago

If you want to avoid putting large contents through remoting, I think your best bet is to make a non-Bolero endpoint and call it from Bolero using HttpClient. I'll add documentation about it soon, but in short you can do something like this:

open System.Net.Http

let post (http: HttpClient, url: string, content: string) =
    http.PostAsync(url, new StringContent(content))
    |> Async.AwaitTask

let update (http: HttpClient) message model =
    match message with
    | ContentsRead contents ->
        model, Cmd.ofAsync post (http, "/postFile", contents) (* ... *)

I'd actually be curious to see what performance difference you get between this and going through remoting. The only difference practically should be the JSON de/serialization.

kos59125 commented 5 years ago

Thanks. I'm considering over GB files to upload so that string would not be appropriate.

Tarmil commented 5 years ago

Ah, this complicates things indeed. You probably want to use something like BlazorFileReader to stream the file contents from js, and post them using HttpClient and StreamContent.

kos59125 commented 5 years ago

Thanks again. I'll have a look at BlazorFileReader.

Bananas-Are-Yellow commented 4 years ago

I am also trying to upload files. It's working in WebAssembly mode, but it does not work if AddBoleroHost has server=true or prerendered=true. Let me explain why.

For the UI, instead of BlazorFileReader, I am using BlazorInputFile by Steve Sanderson. This works fine and can be styled with Bulma.

I've created a simple non-Bolero endpoint in MyApp.Server by adding a MapPost inside UseEndpoints in my Startup.Configure. This receives the uploaded files, which are sent from the client using MultipartFormDataContent and HttpClient.PostAsync.

I've used AddHttpClient to create my HttpClient so that I can initialize the BaseAddress. I did this in Program.Main in the client where I can access the base address from builder.HostEnvironment.BaseAddress.

I'm actually calling AddHttpClient<IFileTransfer, FileTransfer> to define a typed client, so the constructor of my FileTransfer type receives the HttpClient with the base address already set correctly. The MyApp type in the client uses [<Inject>] to obtain the IFileTransfer service.

This all works fine.

The problem is that if I add services in Program.Main in the client, it seems they are not available when the application is running in server mode (AddBoleroHost has server=true) or if the server needs to pre-render (AddBoleroHost has prerender=true). In these cases, accessing the IFileTransfer service fails because it is not found.

Is this to be expected? Do services need to be added in two places?

Assuming so, I want to add it to Startup.ConfigureServices in the server too. In this case, uploading a file will use an HttpClient to upload to "localhost:5000". I can hardwire this, and it works for development, but how should I obtain the base address when using the HttpClient to loop back to the Asp.Net Core server endpoint in this way?

Tarmil commented 4 years ago

Is this to be expected? Do services need to be added in two places?

Yes, that's how it goes: in client mode, the services are setup in Client's main function, and in server mode, in Startup's ConfigureServices.

Assuming so, I want to add it to Startup.ConfigureServices in the server too. In this case, uploading a file will use an HttpClient to upload to "localhost:5000". I can hardwire this, and it works for development, but how should I obtain the base address when using the HttpClient to loop back to the Asp.Net Core server endpoint in this way?

It seems like if you do things this way, then in server mode, you will have two requests: the first one, done by InputFile over SignalR, to send the data to the server-side Blazor; and then from the server to itself, to send it to the Post endpoint. It should be possible to avoid this second one.

If I understand correctly the purpose of your IFileTransfer interface, you could provide two different implementations of it:

Bananas-Are-Yellow commented 4 years ago

Thank you, that's a good solution. Two implementations of IFileTransfer, one with an HttpClientand one without.

Client

In Program.Main we have:

services.AddHttpClient<IFileTransfer, ClientFileTransfer> (fun http ->
    http.BaseAddress <- Uri env.BaseAddress)

The implementation of IFileTransfer posts to the endpoint in the server:

type ClientFileTransfer (http: HttpClient) =
    interface IFileTransfer with
        member _.UploadFiles files =
            async {
                // use MultipartFormDataContent and http.PostAsync
            }

Server

In Startup.ConfigureServices we have:

AddSingleton<IFileTransfer, ServerFileTransfer>()

No uploading required. Just directly call the code that the upload endpoint uses.

type ServerFileTransfer () =
    interface IFileTransfer with
        member _.UploadFiles files = // share code with Http upload endpoint