I'll walk you through a sample project I completed on my Mac at home. The app is a simple web service called "Sports Stats" (here is the full source code) and it's a web service that retrieves stats for a specific baseball player or golfer.
The Setup
Step 1 - Install Atom. It's also possible to use Visual Studio Code, but I chose Atom for this project. Atom is a text editor created by GitHub.
Step 2 - Install Mono. Mono is an open source implementation of .NET that allows you to run .NET applications cross-platform. Eventually you will be able to use coreCLR for this purpose but it's not quite ready yet.
Step 3 - Next comes Ionide. Ionide is an awesome open source package that allows F# development in VSCode and Atom. Install these Ionide packages in Atom.
Step 4 - Install fsharp yeoman. Yeoman will create scaffolding projects and solutions so you don't have to manually create the visual studio project xml files.
Step 5 - Now that everything is installed we start by using yeoman to create the solution. Just start by typing "yo fsharp" in the command line. In this project I created a simple web service so I used the Console application template.
The Dependencies
1. FAKE - F# library for building.
2. Fsharp.Data - F# HTML type provider used for parsing stat web pages.
3. FsUnit - Unit testing library to make f# tests read well. So instead of using Assert.Equals tests could look like this:
result |> should equal 10
4. Newtonsoft.Json - Library for serializing JSON5. Suave - Amazing F# library that allows setting up a fast, lightweight, and non blocking web server.
6. xUnit - Unit testing library that works better than nUnit with F# projects.
7. Paket - Dependency manager for .NET (much better than NuGet).
The Code
I used FAKE to build all the projects. The build script uses Paket to download all dependencies, then builds the projects, then runs tests. Here is the full build.fsx file for reference.
One of the reasons I love writing code in F# is the ability to easily use the REPL. After programming this way there is no going back for me. The F# REPL in Atom isn't perfect yet but it really helps development. This allows for quickly testing of small functions and promotes the use of pure functions in your program.
REPL example |
Entry point:
let routes (db:IDB) =
choose
[ GET >=>
choose [ path "/Golf/LowestTournament" >=> SportService.getLowestTournament db
path "/Golf/LowestRound" >=> SportService.getLowestRound db
path "/Golf/TotalEarnings" >=> SportService.getTotalGolfEarnings db
path "/Baseball/Homeruns" >=> SportService.getHomeruns db
path "/Baseball/Strikeouts" >=> SportService.getStrikeouts db
path "/Baseball/Steals" >=> SportService.getSteals db ]]
[<EntryPoint>]
let main argv =
startWebServer defaultConfig (routes Database.DB)
0
The other good thing about the routes function is that it's fully unit-testable. The database connection is passed in at runtime so it's possible to test HTTP request and responses by simply testing the routes function.
Here is an example of a unit test that does just that.
let fakeDB (response:Response) =
{ new IDB with
member x.GetLowestTournament first last = response
member x.GetLowestRound first last = response
member x.GetTotalGolfEarnings first last = response
member x.GetHomeruns first last = response
member x.GetStrikeouts first last = response
member x.GetSteals first last = response
}
[<Fact>]
let ``Golf lowest tournament total Tiger Woods``() =
let expectedResponse = "{\"FirstName\":\"Tiger\",\"LastName\":\"Woods\",\"Stat\":{\"Case\":\"LowestTournament\",\"Fields\":[-27]}}"
let athlete = defaultAthlete "Tiger" "Woods" (LowestTournament -27)
result "Tiger" "Woods" "Golf\LowestTournament" (fakeDB athlete)
|> validateSuccess expectedResponse
This unit test creates a fake database on the fly and passes that database into the routes function. The HTTP response is then fully validated. This unit test provides a lot of value and actually helped me quite a few times in development when I broke some of the routes by accident.
Eventually after the route is matched and its corresponding function is called the Fsharp.Data HTML type provider is used. The type provider loads the specified HTML page and parses through it appropriately. The parsing code I wrote is a little dirty because the page I used for getting the stats is created dynamically and didn't have good class names. Here is the parsing code for the golf stats.
let stat (html:HtmlDocument) (input:GolfInput) =
let tables = html.Descendants ["table"]
match Seq.length tables with
| 0 -> Failure RecordNotFound
| _ -> let value =
tables
|> Seq.head
|> (fun x -> x.Descendants ["tbody"])
|> Seq.head
|> (fun x -> x.Descendants ["tr"])
|> Seq.map (input.MapFunction input.Data.ColumnIndex)
|> Seq.filter input.FilterFunction
|> input.TotalFunction
Success { FirstName = input.Data.FirstName
LastName = input.Data.LastName
Stat = input.Data.ValueFunction value }
This is also fully unit-testable. I simply pass in a sample HTML page and verify the result like so.
[<Literal>]
let golfHtml =
"""<html>
<body>
<table>
<tbody>
<tr>
<td>login</td>
<td>Win</td> <!-- Final finish -->
<td>61-67-70-71=269</td> <!-- Final score -->
<td>-27</td> <!-- Final score to par -->
<td>$864,000</td> <!-- Final money -->
<td>fedex</td>
</tr>
<tr>
<td>login</td>
<td>T15</td> <!-- Final finish -->
<td>66-71-70-71=278</td> <!-- Final score -->
<td>-28</td> <!-- Final score to par -->
<td>$1,997,000</td> <!-- Final money -->
<td>fedex</td>
</tr>
<tr>
<td>login</td>
<td>Win</td> <!-- Final finish -->
<td>72-71-70-71=284</td> <!-- Final score -->
<td>-18</td> <!-- Final score to par -->
<td>$322,000</td> <!-- Final money -->
<td>fedex</td>
</tr>
<tr>
<td>login</td>
<td>T33</td> <!-- Final finish -->
<td>58-77-64-60=259</td> <!-- Final score -->
<td>-17</td> <!-- Final score to par -->
<td>$659,000</td> <!-- Final money -->
<td>fedex</td>
</tr>
</tbody>
</table>
</body>
</html>"""
[<Fact>]
let ``Golf lowest round``() =
let input = { FirstName = "Tiger"; LastName = "Woods"; ColumnIndex = 2; ValueFunction = LowestRound }
let golfInput = { Data = input; MapFunction = GolfStats.lowestRoundMap; FilterFunction = (fun x -> x > 50); TotalFunction = Seq.min }
let expected = Success { FirstName = "Tiger"; LastName = "Woods"; Stat = LowestRound 58}
let doc = HtmlDocument.Parse golfHtml
(GolfStats.stat doc golfInput)
|> should equal expected
Here is the end result. A beautiful front-end showcasing my work!
Simple front-end using the API |
The bad - I couldn't get the FSI REPL to work with the FSharp.Data type provider. This was a shame because (as far as I know) debugging is not enabled in Atom with Ionide. Because of the limitation it made it difficult to write some of the HTML parsing code. Also, adding new files to the project was painful because manually editing the .fsproj files was error prone.
The good - Love the fact I can create F# .NET apps on a Mac without running a windows VM. Atom and Ionide work well together and this app was created with all open source packages and software. Given this process would also run on linux it is possible to create first class, scalable web services that would be inexpensive to host. It's close to becoming a viable option for a startup in my opinion.