Background
After many years of building Web APIs with C# and (mostly) Azure, I have recently started a new role where F# and AWS are used. Learning a new language and programming style has been challenging but lots of fun. As an onboarding exercise, I've been building a simple minimal Web API hosted on AWS Lambda with dotnet 6. This will later (hopefully) evolve into a useful internal product, which I won't go into yet. But in the meantime, here are the simple steps I followed to get an API up and running.
I also describe an approach to adding logging with Serilog, which may be useful for OOP devs who hit a wall when trying to figure out what to do in the more general scenario where they would normally inject an abstract dependency into a constructor.
It should be noted that I am brand new to F# and functional programming in general, so none of this should be considered definitive. It simply documents my learnings and should hopefully provide some help to new F# devs used to coding in an OOP style. No doubt my approach will change as I learn more.
Pre-requisites
I started by installing the dotnet 6 SDK from Microsoft.
You will need an AWS account. Mine was already set up and my profile had fresh credentials. There are instructions here if you need help creating an access key ID and secret access key.
I installed the AWS CLI and AWS SAM CLI. The AWS CLI allows you to interact with AWS from the command line. The AWS SAM CLI is needed for developing and maintaining serverless applications on AWS, such as Lambda functions.
I am using Jetbrains Rider as my IDE, which has bundled F# support. I installed the AWS Toolkit as a plugin to Rider.
I installed the AWS Lambda tooling and project templates as follows:
dotnet tool install -g Amazon.Lambda.Tools
dotnet new -i Amazon.Lambda.Templates
I created an S3 bucket to use for publishing my application:
aws s3api create-bucket —bucket MinimalApiDemoBucket
You can use any unique name for the bucket. Typically I would follow the more sensible naming convention in use by my org, but for a demo, MinimalApiDemoBucket will do.
Creating the Solution
Within Rider, I clicked on File -> New. In here I could see the many templates for creating Lambda functions. I chose Lambda ASP.NET Core Web API with Language F#.
Click Create.
The first time I did this, I hit an issue (apparently with Rider?) where the solution appears to load correctly in the IDE, but taking a look at the underlying files shows they are corrupted. Re-trying this create step seemed to work.
Optional step: I'm not a big fan of how Rider structures solutions, so I did some re-arrangements. I updated all namespaces and module names to mirror my updated folder structure. My final structure is like this:
Modifications to Project Files
App.fsproj
and App.Tests.fsproj
both need to be modified to target net6.0
instead of netcoreapp3.1
.
Instead of:
Use this:
Finally, update the serverless.template
file to include the Architectures value:
Get the API working locally
Restore and build the solution.
Right-click on the App project and then select Run. You should see something similar to this in the console of your IDE:
As you can see the app is running on port 5000 for http and 5001 for https.
While the app is running, you can test out the endpoints with Postman:
Success!
For further validation, you can run the out-of-the-box unit tests in App.Tests
. This test project gives you a good template to build on.
Deploy on the AWS cloud
Deploying the Lambda function on AWS is very simple. In the terminal, run the following command:
dotnet lambda deploy-serverless --stack-name MinimalApiDemo --s3-bucket MinimalApiDemoBucket --tags "OWNER=louisekeegan"
I added a tag to make it easier to locate resources in the AWS Console later on, but this is optional. You can also include your AWS profile (--profile MyProfile
) in the above command if you have issues with AWS choosing a default profile that you don't want to use.
The application will build and publish.
The AWS CLI will prompt you to enter the path to serverless.template
. For this, enter src/App/serverless.template
. You may also be asked to enter the AWS region to use. In my case this was us-west-2
, so I entered this.
All going well, the URL for the Lambda function will be returned on the console and you can then test this out in Postman by making a GET command to https://{the-returned-url}/api/values
.
You should also now be able to see the resources you created within the AWS Toolkit window in Rider. Just select your profile and region, then click on Lambda and you should see your function displayed here. The S3 bucket created earlier should also be visible.
Injecting a Logger?
When a Lambda function runs in AWS, anything logged to the console or using a log provider will automatically be visible in CloudWatch in the AWS console.
I wanted to add logging with Serilog, but this threw up some interesting questions around architecture and dependency injection. In the object-oriented world of C# a logger of type Microsoft.Extensions.Logging.ILogger
would typically be injected into the constructor of a class where you want to log something, for example the controller, in this case ValuesController
. You would register an implementation of ILogger
with the dependency injection container in Startup.cs
and this would then be resolved for use at runtime inside the controller. This can also be done in F#, but is not really in keeping with functional programming conventions.
I read an interesting article, which made me decide on a different approach. I would instead pass function calls to LogInformation
and LogError
etc as input parameters to my API endpoints, along with whatever payload might be expected. These logging functions could then be invoked at runtime when handling requests. They would need to be wired up inside Startup.fs
using a single logger instance.
Once I decided to do this, i.e. to not inject dependencies into a controller constructor, the controller itself seemed a bit superfluous. I decided to instead go with minimal APIs as it just seemed a natural fit.
Note: the above-mentioned article is highly recommended reading for any OOP devs learning how to code in a functional way.
Add Logging and Switch to Minimal API
To demonstrate the approach, we will now expose GET and POST minimal endpoints, which will log something.
First of all, I installed two nuget packages in the App project:
Serilog
Serilog.Sinks.Console
Inside Startup.fs
, import the Serilog assembly:
open Serilog
Add the following function inside Startup.fs
.
member this.GetLogger =
let loggerConfiguration = LoggerConfiguration()
let logger = loggerConfiguration.WriteTo.Console().CreateLogger()
logger
The purpose of this function is to return an instance of the Serilog logger, which will only be used inside Startup.fs
.
Add an endpoint handler function for the GET request, again inside Startup.fs
:
let handleGet logInfo =
logInfo $"Request received"
[|"value1"; "value2"|]
For the POST endpoint, we would like to send some data as JSON with our request, so let's define a type for it inside a separate F# file called Request.fs
.
module App.Request
type Payload = {
property1: string
property2: int
}
Remember to import your Request.Payload
type into Startup.fs
:
open App.Types
Also make sure that Request.fs
is the first item in the build order inside App.fsproj
:
<ItemGroup>
<Compile Include="Request.fs" />
<Compile Include="Startup.fs" />
<Compile Include="LambdaEntryPoint.fs" />
<Compile Include="LocalEntryPoint.fs" />
</ItemGroup>
Add another endpoint handler for the POST request:
let handleCreate (payload: Request.Payload) logInfo =
logInfo $"Payload received: {payload}"
()
Finally, update this.Configure
to map the newly added endpoint handlers to their routes and pass in the logger function:
member this.Configure(app: IApplicationBuilder, env: IWebHostEnvironment) =
let logger = this.GetLogger
let logInfo = logger.Information
if (env.IsDevelopment()) then
app.UseDeveloperExceptionPage() |> ignore
app.UseHttpsRedirection() |> ignore
app.UseRouting() |> ignore
app.UseAuthorization() |> ignore
app.UseEndpoints(fun endpoints ->
endpoints.MapPost("api/values", Action<Request.Payload>(fun payload -> handleCreate payload logInfo)) |> ignore
endpoints.MapGet("api/values", Func<string[]>(fun () -> handleGet logInfo)) |> ignore
) |> ignore
Notice that the call to MapControllers has been removed:
// Previously we had this:
app.UseEndpoints(fun endpoints ->
endpoints.MapControllers() |> ignore
) |> ignore
You can now delete the ValuesController
as it is no longer being used.
And that's it. You can now build, run and test the new endpoints with Postman by sending a GET and POST request to https://localhost:5001/api/values
as before. Confirm that the request gets logged to the console as expected, and that in the case of the POST endpoint, the Request.Payload
object gets logged too. You can also deploy to AWS using the same command as previously.
dotnet lambda deploy-serverless --stack-name MinimalApiDemo --s3-bucket MinimalApiDemoBucket --tags "OWNER=louisekeegan"
Conclusion
Creating the F# WebAPI and deploying to AWS is relatively simple. There are only a few tweaks needed to get it up and running (updating the serverless.template
function properties) and targeting dotnet 6. Beyond that, switching to minimal APIs is trivial and seems to work quite nicely in a scenario like this with a relatively lightweight application, written in F# and hosted on AWS Lambda. Adding logging is straightforward: you just install the packages, create a single logger instance in Startup.fs
and then pass logger function calls as parameters into your endpoints. I expect I will follow this same approach for managing other dependencies in future as it should be easy to mock in unit tests. Hopefully this guide proves useful to others checking out F# and AWS Lambda.
The code for this can be viewed here.
Top comments (1)
Very nice! I enjoyed seeing how you wired up the logger with partial application in the endpoint configuration. That's a very nice way to handle that.