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.
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.
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
typeDataSource() = memberval Description: string = ""with get, set memberval ConnectionString: string = ""with get, set memberval Enabled: bool = falsewith get, set
typeEndPoint() = memberval Description: string = ""with get, set memberval Uri: string = ""with get, set memberval Timeout: int = 0with get, set memberval Enabled: bool = falsewith get, set
typeConfig() = memberval Stage: string = ""with get, set memberval Version: string = ""with get, set memberval BatchSize: int = 10000with get, set memberval EndPoints: Dictionary<string, EndPoint> = new Dictionary<string, EndPoint>() with get, set memberval DataSources: Dictionary<string, DataSource> = new Dictionary<string, DataSource>() with get, set memberval Events: List<string> = new List<string>() with get, set memberval 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())
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.