Create a Minimal F# Web API with dotnet 6 and Serilog on AWS Lambda
Tags: fsharp, dotnet, serverless, aws
Published: 2022-11-21 11:01:20

## 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](https://dotnet.microsoft.com/en-us/download/dotnet/6.0) from Microsoft.

You will need an AWS account. Mine was already set up and my profile had fresh credentials. There are instructions [here](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-prereqs.html) if you need help creating an access key ID and secret access key.

I installed the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) and [AWS SAM CLI.](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) 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#.

![New Solution Dialog in Rider](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6ez1ds6qstd5f34ul2tg.png)

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:

![Optional folder structure](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wk7n7htcn68ulcqxparp.png)


### 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:

![App.fsproj before](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hz7s3w44z5ae4ma7l1us.png)

Use this:

![App.fsproj after](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y4udnfa6wk079xkvmgll.png)

Finally, update the `serverless.template` file to include the Architectures value:

![serverless.template updates](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b4o93sht0w3e97mv8qfh.png)


### 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:

![Console output when running locally](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a6kc2l0nq286xszpmslv.png)

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:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h5ru6kb02kvpg5whco5v.png)

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](https://fsharpforfunandprofit.com/posts/dependency-injection-1/), 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](https://github.com/louisekeegan/FSharpMinimalApiLambdaDemo).