Yaml and F#

Read Time: 7 minutes

Yaml is a useful format for both configuration and simple data representation that pops up from time to time. As a result, it is helpful to know how to use it and coding in F#. Today I’ll take a quick look into using Yaml and how it can be integrated into an F# project.

There are several Yaml parsers in the .NET ecosystem; for this post I’ll use YamlDotNet. It has a direct interface and decent documentation. For the samples below I’ll use dotnet add package YamlDotNet --version 12.3.1.

Below is a sample file, config.yml. It is a mocked config file that could be seen for any random service. To show some of the options available, it contains scalar (string and numeric), list, and object structures. I’ll take this file as the case study to convert into F# types.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
stage: dev
version: 1.2.3
batchSize: 1000
events:
- insert
- update
- query
- delete
endPoints:
backup:
description: Data backups
uri: https://example.com/backup
timeout: 10
enabled: true
metrics:
description: Data backups
uri: https://example.com/metrics
timeout: 10
enabled: true
dataSources:
users:
description: string
connectionString: postgresql://user1:[email protected]:5432/postgres
enabled: true
data:
description: string
connectionString: postgresql://user2:[email protected]:5432/postgres
enabled: true
notes: |
This service should run periodically.
Check further documentation on the website for execution details.

Before I can work with the yaml, I’l need to define the types and structure of the config file. A couple notes to get this to all work. One, the types must be annotated with [<CLIMutable>]. Also, I generally prefer to use F#’s Map, but for this case I need to use a Dictionary. The serialization/deserialization doesn’t work otherwise. This is all fine, it is just one of the compromises we F# devs need to make sometimes. The structure of the file is represented with the Config type. In addition to standard scalars and a list, it uses DataSource and Endpoint types.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
open System
open System.Collections.Generic
open YamlDotNet.Serialization
open YamlDotNet.Serialization.NamingConventions

[<CLIMutable>]
type DataSource = {
Description: string
ConnectionString: string
Enabled: bool
}

[<CLIMutable>]
type EndPoint = {
Description: string
Uri: string
Timeout: int
Enabled: bool
}

[<CLIMutable>]
type Config = {
Stage: string
Version: string
BatchSize: int
EndPoints: Dictionary<string, EndPoint>
DataSources: Dictionary<string, DataSource>
Events: List<string>
Notes: string
}

As can often be the case, there are multiple ways to do things. I prefer the above way to define types (using record types), but the types can be defined as classes with explicit getters and setters. For the most part this is a stylist choice, but I wanted to provide both options.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type DataSource () =
member val Description: string = "" with get, set
member val ConnectionString: string = "" with get, set
member val Enabled: bool = false with get, set

type EndPoint () =
member val Description: string = "" with get, set
member val Uri: string = "" with get, set
member val Timeout: int = 0 with get, set
member val Enabled: bool = false with get, set

type Config () =
member val Stage: string = "" with get, set
member val Version: string = "" with get, set
member val BatchSize: int = 10000 with get, set
member val EndPoints: Dictionary<string, EndPoint> = new Dictionary<string, EndPoint>() with get, set
member val DataSources: Dictionary<string, DataSource> = new Dictionary<string, DataSource>() with get, set
member val Events: List<string> = new List<string>() with get, set
member val Notes: string = "" with get, set

Below I have the serialization and deserialization examples. For my case I’m using camel casing in the yaml files, but other naming conventions are supported: Hyphenated, PascalCase, Underscored, LowerCase. The process is pretty simple: create a builder, then serialized/deserialize as appropriate. For the deserializer, I added some error handling, always a good practice.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// Serialize config to yaml
let configToYaml (config: Config) :string =
let serializer =
SerializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build()

serializer.Serialize(config)

...

// Save config
let yaml = configToYaml config
IO.File.WriteAllText("config_new.yml", yaml)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// Deserialize yaml into Config object 
let deserializeConfig (s: string) :Result<Config, string> =
let deserializer =
DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build()

try
Ok(deserializer.Deserialize<Config>(s))
with
| e -> Error (e.ToString())

...

// Read config
let config =
"config.yml"
|> IO.File.ReadAllText
|> deserializeConfig

Sometimes you don’t know the specific structure of the yaml. The library provides a stream processor that allows an alternative method to navigate the yaml. It isn’t as convenient and you have to worry about casting errors, but it works. Below I have an example of some simple data extraction. For readability I’ve excluded error handling from the example, but some try..with is definitely a requirement here with the necessary downcasting.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use configReader = new System.IO.StreamReader("config.yml")
let yaml = YamlStream()
yaml.Load(configReader)

// root node
let root = yaml.Documents[0].RootNode :?> YamlMappingNode

// Show top level nodes
printfn "top level:"
for entry in root.Children do
printfn " %s" (entry.Key :?> YamlScalarNode).Value

// Iterate through events
let events = root.Children[YamlScalarNode("events")] :?> YamlSequenceNode

printfn "events:"
for event in events do
printfn " %A" event
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Output: 
top level:
stage
version
batchSize
events
endPoints
dataSources
notes
events:
insert
update
query
delete

This is a bit of a divergence, but the library also provides a nice option for json serialization. So if you ever run into the case where you need to convert, this can do the trick.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// Serialize config to json
let configToJson (config: Config) :string =
let serializer =
SerializerBuilder()
.JsonCompatible()
.Build()

serializer.Serialize(config)

...

let json = configToJson config
printfn "%s" json
IO.File.WriteAllText("config_new.json", json)

This has been a pretty light post, but if you have a quick need to use yaml with F#, hopefully you find this useful. Until next time.