Hello there folks!
It has been quite a while (once again hah!) while I've been busy working in a few of my own F# projects the truth is that I didn't have much to write about in F# after my last "Simple things in F#" series.
Today I don't have a new series to start with but rather a simple example which may or may not grow in another blog series. For the moment we'll talk about how to do File uploads to an F# backend powered by Falco and Saturn so let's get started!
The samples for this post can be found at this repository:
File Upload Sample
This is a small sample that shows how to upload a file from an HTML Form to an F# backend.
The backends here are
While they're mainly the same as they both are built on top of ASP.NET it is nice to have a focused sample that works as a reference in case you need it.
You can check the post for this content now https://dev.to/tunaxor/f-file-uploads-with-saturn-and-falco-2ffh :)
So the situation is the following
You just found F# or saw a tweet/toot/thread related to F# and decided to give it a go, you've been told there are F# web frameworks like falco and saturn but you haven't seen a lot of samples out there, it would be nice if there's a focused sample you can take a look at.
Well let me tell you that I've been there as well! Well... in my case it was with php, then with node, then with C# and at some point F# as well hah! But I digress, Let's start with our client side where we'll be sending files to our back-end.
HTML Forms
At this point in time, HTML is present everywhere so this is where we will start, given the following HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Send a File to the Server</title>
</head>
<body>
<!-- Standard HTML Post -->
<form id="standard">
<fieldset>
<legend>Standard Html Upload</legend>
<!-- don't forget the name, it is quite important! -->
<input type="file" name="my-uploaded-file" />
<!-- trigger the submit event with this button/input -->
<input type="submit" value="Upload" />
<progress id="standard-progress" style="opacity: 0"></progress>
</fieldset>
</form>
<!-- Once the server answers our request we'll set the responses below -->
<section id="response-target"></section>
<section id="error-target"></section>
<!-- Let's also sprinkle vanilla JS here to make it a little bit dynamic -->
<script>
// Let us wait for the DOM to load, so we can find our HTML nodes
document.addEventListener("DOMContentLoaded", () => {
// let's gather the elements we'll be working with
const form = document.querySelector("#standard");
const target = document.querySelector("#response-target");
const errorTarget = document.querySelector("#error-target");
const progress = document.querySelector("#standard-progress");
form.addEventListener("submit", (e) => {
// avoid page reloads we'll send our request manually
e.preventDefault();
// these days building a form data is so simple
// just pass the form reference to the constructor and
// all inputs related to the form will be added automatically
const formData = new FormData(form);
progress.style.opacity = 1;
// We'll use a standard fetch here for simplicity
fetch("/uploads", {
method: "POST",
body: formData,
})
.then((response) =>
// if the status code is not ok, then we'll send the text to the catch handler
response.ok
? response.text()
: // remember text is a promise, so we'll need to extract that there
response.text().then((content) => Promise.reject(content))
)
.then((response) => {
// if all goes well then we'll swap our target's html
// with the server's response
console.log("Success");
target.innerHTML = response;
})
.catch((error) => {
// you can reject promises with anything not just Errors so we'll check
if (error instanceof Error) {
errorTarget.textContent = error.message;
return;
}
// if it was not an error then it is likely we're here due to our
// handler above
errorTarget.innerHTML = error;
})
.finally(() => {
// hide the progress in any case
progress.style.opacity = 0;
});
});
});
</script>
</body>
</html>
Our client side HTML is as simple as we can get it, just a form and a few lines of JavaScript to avoid a page reload. The form submission will hit the /uploads
endpoint in our server, this index file will be server by our server as well so no need to point to the full address (also to leave CORS out of the situation for the moment).
Saturn Back-end
Fortunately our sample is so focused that it can be implemented in a file with around... 50-60 LoC
open System
open System.IO
open Microsoft.AspNetCore.Http
open Saturn
open Giraffe
open Giraffe.ViewEngine
// This is a templating function to produce HTML content that we'll use later on
let responseTemplate color content =
// Giraffe.ViewEngine uses a tagElement attribute list tagElement list
article
[ _style $"color: %s{color}" ]
[ header [] [
// encodedText is a function that produces a string with encoded html,
// this avoids sending raw html to the client from the server
h3 [] [ encodedText "File Contents below:" ]
]
pre [] [ encodedText content ]
]
// the index handler is quite simple, just send the html file which is located by
// the relative path to the current working directory of the server
let index next context = htmlFile "./index.html" next context
// This is our File Uploads Handler
let handler next (context: HttpContext) =
task {
// Saturn/Giraffe can also use aspnet's features directly
let form = context.Request.Form
// From the ASPNET's form abstraction we try to get a single file with the name
// we provided, we also convert it to an option to handle potential null values
let extractedFile = form.Files.GetFile "my-uploaded-file" |> Option.ofObj
// if we wanted to extract multiple files we'd use something like form.Files.GetFiles "input's name attribute"
match extractedFile with
| Some file ->
// if the file is present in the request, then we can do anything we want here
// from validating size, extension, content type, etc., etc.
// For our use case we'll create a disposable stream reader to get the text content of the file
use reader = new StreamReader(file.OpenReadStream())
// in our simple use case we'll just read the content into a single string
let! content = reader.ReadToEndAsync()
// we'll write the file to disk just as a sample
// we could upload it to S3, Google Buckets, Azure Storage as well
do! File.WriteAllTextAsync($"./{Guid.NewGuid()}.txt", content)
// We received a file and we've "processed it" successfully
let content = responseTemplate "green" content
// send our HTML content to the client and that's it
return! htmlView content next context
| None ->
// The file was not found in the request well return an error message
let content = responseTemplate "tomato" "The file was not provided"
// and also set our status code to 400 to signal it was a client error
return! (setStatusCode 400 >> htmlView content) next context
}
// lastly register our routes in the router
let appRouter =
router {
get "/" index
get "/index.html" index
post "/uploads" handler
}
// create a server
let server = application { use_router appRouter }
// run it baby!
run server
That's it! Nothing too crazy I hope!
Let's put that into an F# project
# create the project
dotnet new console -lang F# -o saturn-sample
cd saturn-sample
# add the Saturn package
dotnet add package Saturn
# create the empty index file
touch index.html
Now copy and paste the contents of the previously provided client side HTML into the index.html
file, then copy F# source code and replace the contents of the Program.fs
file.
and start your app.
dotnet run
You should be able to visit http://localhost:5000/
and start testing the file upload.
Falco Back-End
Fortunately, the falco sample is not much different and also fits in a single file! we'll be using the same index.html
that we shown above so we'll go straight to the F# source code for this.
open System
open System.IO
open System.Threading.Tasks
open Microsoft.AspNetCore.Http
open Falco
open Falco.Routing
open Falco.HostBuilder
open Falco.Markup
// Similarly to the saturn sample
// we'll provide a templating function
// this one uses Falco.Markup though rather than Giraffe.ViewEngine
let responseTemplate color content =
Elem.article
[ Attr.style $"color: %s{color}" ]
[ Elem.header [] [ Elem.h3 [] [ Text.enc "File Contents below:" ] ]
Elem.pre [] [ Text.enc content ] ]
// equally to the previous sample we'll read the index file and return an html response
let index (context: HttpContext) : Task =
task {
// the only difference here is that we are able to pass the request aborted cancellation token
let! content = File.ReadAllTextAsync("./index.html", context.RequestAborted)
return! Response.ofHtmlString content context
}
let handler context : Task =
task {
// Falco can also use aspnet's features directly
// but offers an F# API for ease of use
let! form = Request.streamForm context
let extractedFile =
// extract the file safely from the
// IFormFileCollection in the http context
form.Files
|> Option.bind (fun form ->
// try to extract the uploaded file named after the "name" attribute in html
// GetFile returns null if no file is present, so we safely convert it into an optional value
form.GetFile "my-uploaded-file" |> Option.ofObj)
match extractedFile with
| Some file ->
// if the file is present in the request, then we can do anything we want here
// from validating size, extension, content type, etc., etc.
// For our use case we'll create a disposable stream reader to get the text content of the file
use reader = new StreamReader(file.OpenReadStream())
// in our simple use case we'll just read the content into a single string
let! content = reader.ReadToEndAsync()
// we'll write the file to disk just as a sample
// we could upload it to S3, Google Buckets, Azure Storage as well
do! File.WriteAllTextAsync($"./{Guid.NewGuid()}.txt", content)
// We received a file and we've "processed it" successfully
let content = responseTemplate "green" content
// send our HTML content to the client and that's it
return! Response.ofHtml content context
| None ->
// The file was not found in the request return something
let content = responseTemplate "tomato" "The file was not provided"
return! context |> Response.withStatusCode 400 |> Response.ofHtml content
}
// declare the host and the endpoints
webHost [||] {
endpoints
[
// handle the index routes as well
get "/" index
get "/index.html" index
// our file endpoint handler as well
post "/uploads" handler ]
}
Again, let's put that into an F# project
# create the project
dotnet new console -lang F# -o falco-sample
cd falco-sample
# add the Falco package
dotnet add package Falco
# create the empty index file
touch index.html
Now copy and paste the contents of the previously provided client side HTML into the index.html
file, then copy F# source code and replace the contents of the Program.fs
file.
and start your app.
dotnet run
You should be able to visit http://localhost:5000/
and start testing the file upload.
Both backends in F# are virtually the same a few differences here and there might make it more appealing for some than others but the premise is the same, using F# should make it easy and safe regardless of your preferred style and we can see this in using the F# Optional type to avoid null reference exceptions which can bite hard in languages where null is the day to day bread.
Please note the usage of the use
keyword in the samples, for disposable references (the ones that implement the IDisposable
interface) F# can make the disposition automatically at the end of the scope when this keyword is used, otherwise you would need to manually call reference.Dispose()
which can be easily forgotten so... avoid that and just let F# handle it for you!
I must mention that when you're dealing with file uploads you should avoid reading the contents into a string, this is for performance reasons mainly because rarely you actually need to inspect the contents of the file you usually pass it on to another file upload service like I mentioned in the sample, aws, gcloud, azure, you name it letting the code process a stream is better as you don't allocate the string in memory for un-needed reasons.
Bonus HTMX
You may have heard of this HTMX thing... that supposedly makes it easier to deal with client side HTML + any existing back-end.
Well, let me tell you that it works wonders with F# as well!
to use HTMX we'll change our HTML a little bit:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Send a File to the Server</title>
<!-- Include HTMX in your website -->
<script src="https://unpkg.com/htmx.org@1.9.3"></script>
</head>
<body>
<!-- HTMX uses hx-* attributes to indicate the behavior of the element that will send
an http request, in this case
we're telling htmx to post a form-data request to the /uploads endpoint
and when the server sends a response replace the inner html of the response target
with the server's response
-->
<form
id="htmx-form"
hx-encoding="multipart/form-data"
hx-post="/uploads"
hx-target="#response-target"
hx-swap="innerHtml"
>
<fieldset>
<legend>HTMX Upload</legend>
<input type="file" name="my-uploaded-file" />
<button>Upload</button>
<progress id="htmx-progress" value="0" max="100"></progress>
</fieldset>
</form>
<section id="response-target"></section>
<section id="error-target"></section>
<!-- Closely to vanilla JS, but with a smoother API to work with -->
<script>
// given the element (specified by the selector) listen to the progress event in the
// backing hxr request
// crafting an xhr request manually is quite lengthy so that's why we used fetch in the plain
// html example, htmx thankfully hides that for us and lets us focus on the omportant bits
htmx.on("#htmx-form", "htmx:xhr:progress", function (evt) {
htmx
.find("#htmx-progress")
.setAttribute("value", (evt.detail.loaded / evt.detail.total) * 100);
});
// if the backend sends an error we'll replace the contents of the error target instead
htmx.on("htmx:responseError", (error) => {
evt.detail.target = htmx.find("#error-target");
});
</script>
</body>
</html>
You can replace the contents of the index.html
file with that and it should work just as great, with the difference that we need to write a little bit of JS ourselves and we also get file upload progress as well!
Closing thoughts
Hopefully this shows that F# is not an alien language and it is more similar than you think, no weird maths, no weird point free code. This is very close to what you would write in other languages like typescript given that they provide a framework similar to aspnet!
Does this help you at all?
Do you want more content like this?
Until the next time, you know where to find me!
- @angelmunoz@misskey.cloud - Fediverse presence (like mastodon).
- @angel_d_munoz@threads.net - We're getting started here :P .
- @angel_d_munoz - We'll take the beatings until the ship sinks or if we get bored.
Top comments (2)
Really interesting article, especially the html vs. htmx comparison. Thanks for the bite size lessons ;)
Thank you for taking the time, I try to put these often but sometimes I'm just not able to do so reliably 😅