Recursive AI

Migrating from YamlDotNet

All oneyaml C# docs

There are two ways to move code written against YamlDotNet:

  1. Swap to the adapter. OneYaml.Compat.YamlDotNet provides the YamlDotNet serializer builders and representation model under new namespaces. For the API listed below, changing the package and the using lines keeps your call sites, and YAML 1.1 forms read as YamlDotNet reads them.
  2. Move to the OneYaml API. YamlSerializer and YamlDocument are YAML 1.2, bounded for untrusted input, and can edit a file without losing its comments. This is the route for new code.

You can do the first now and the second file by file: the two packages work side by side.

Before you swap

A build that passes after the swap is not yet a finished migration. Check these four things, in this order:

CheckWhat to look for
Compile gapsAPI the adapter does not provide fails to build: the scanner, MergingParser, type inspectors, node deserializers, event emitters, object-graph visitors, static contexts and comment events. The full list.
Runtime rejectionsDeserialize(IParser) with a parser that is not the adapter's own Parser throws NotSupportedException. A serializer built with EnsureRoundtrip() throws NotSupportedException when Serialize is called: the adapter cannot do what it asks, so it says so instead of ignoring it.
Semantic changesSerializer output can differ (more quoting, escapes), more valid YAML is read, the class of an exception can differ, and after you change a node YamlStream.Save writes without comments. See Check after the swap and the list of differences.
Safety limitsThe depth limit and the alias budget always apply, and WithMaximumRecursion cannot raise the depth above the library limit. For input you do not control, pass YamlLimits.Untrusted to WithLimits (Input you do not control). WithLimits is an addition: YamlDotNet has no such setting.

Swap to the adapter

Terminal
dotnet remove package YamlDotNet
dotnet add package OneYaml.Compat.YamlDotNet
BeforeAfter
using YamlDotNet.Serialization;using OneYaml.Compat.YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;using OneYaml.Compat.YamlDotNet.Serialization.NamingConventions;
using YamlDotNet.RepresentationModel;using OneYaml.Compat.YamlDotNet.RepresentationModel;
using YamlDotNet.Core;using OneYaml.Compat.YamlDotNet.Core;

Before, with YamlDotNet 18.1.0:

C#
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
 
IDeserializer deserializer = new DeserializerBuilder()
    .WithNamingConvention(CamelCaseNamingConvention.Instance)
    .IgnoreUnmatchedProperties()
    .Build();
var service = deserializer.Deserialize<Service>("name: web\nreplicas: 2\nlegacy: true\n");
 
ISerializer serializer = new SerializerBuilder()
    .WithNamingConvention(CamelCaseNamingConvention.Instance)
    .Build();
Console.Write(serializer.Serialize(service));
// name: web
// replicas: 2
 
class Service
{
    public string Name { get; set; } = "";
    public int Replicas { get; set; }
}

After, with the adapter; only the using lines changed, and the output is the same:

C#
using OneYaml.Compat.YamlDotNet.Serialization;
using OneYaml.Compat.YamlDotNet.Serialization.NamingConventions;
 
IDeserializer deserializer = new DeserializerBuilder()
    .WithNamingConvention(CamelCaseNamingConvention.Instance)
    .IgnoreUnmatchedProperties()
    .Build();
var service = deserializer.Deserialize<Service>("name: web\nreplicas: 2\nlegacy: true\n");
 
ISerializer serializer = new SerializerBuilder()
    .WithNamingConvention(CamelCaseNamingConvention.Instance)
    .Build();
Console.Write(serializer.Serialize(service));
// name: web
// replicas: 2
 
class Service
{
    public string Name { get; set; } = "";
    public int Replicas { get; set; }
}

What the adapter covers

  • Serialization: DeserializerBuilder and SerializerBuilder with WithNamingConvention, WithEnumNamingConvention, WithTypeConverter, WithTagMapping, WithAttributeOverride, IgnoreUnmatchedProperties, WithCaseInsensitivePropertyMatching, WithDuplicateKeyChecking, WithAttemptingUnquotedStringTypeDeserialization, WithMaximumRecursion, WithTypeMapping, WithObjectFactory, ConfigureDefaultValuesHandling, WithQuotingNecessaryStrings, WithDefaultScalarStyle, DisableAliases, JsonCompatible, WithNewLine and WithIndentedSequences; IDeserializer, ISerializer, the six naming conventions, [YamlMember], [YamlIgnore], [YamlConverter], IYamlTypeConverter and IYamlConvertible.
  • RepresentationModel: YamlStream, YamlDocument, YamlMappingNode, YamlSequenceNode, YamlScalarNode, visitors, node positions.
  • Core: Parser, Emitter, the event classes, Mark and the exception types.

The list of differences names what is not provided, such as the scanner, MergingParser, type inspectors, node deserializers and static contexts. Code that uses them does not compile, so the gap shows at build time.

The adapter's representation model reads a file and writes it back as it was when nothing changed:

C#
using OneYaml.Compat.YamlDotNet.RepresentationModel;
 
var stream = new YamlStream();
stream.Load(new StringReader("# service settings\nname: web\nreplicas: 2   # set by CI\n"));
 
var root = (YamlMappingNode)stream.Documents[0].RootNode;
Console.WriteLine(root["replicas"]); // 2
 
var text = new StringWriter();
stream.Save(text);               // nothing was changed, so the text comes back as it was read
Console.Write(text);
// # service settings
// name: web
// replicas: 2   # set by CI

After you change a node, Save writes the model again without comments, as YamlDotNet does. To change a file and keep its comments, use YamlDocument (below).

Check after the swap

The adapter reproduces YamlDotNet's documented behaviour, not its defects. Read the full list of differences, each with a repro, and check these first:

  • Output that reads back. Strings that a reader would take for something else are quoted ('true', 'yes', '123'), characters that YamlDotNet writes in a way that changes on reading are escaped, and long keys are written so that they read back. Snapshot tests of serializer output may need updating.
  • Comments survive an unchanged round trip. YamlStream.Save returns the loaded text when nothing changed; YamlDotNet writes it again without comments and with ... after each document.
  • More valid YAML is read. Some inputs that YamlDotNet refuses or throws ArgumentException on are valid YAML 1.2 and are read.
  • Limits stay on. The deserializer enforces the alias budget and the depth limit. An alias into object or a collection gives back the same instance, and every use is charged to the alias budget as if it were expanded, so a billion-laughs input fails with a YamlException. For input you do not control, call WithLimits(YamlLimits.Untrusted) on the builder, or pass YamlLimits.Untrusted to YamlStream.Load or new Parser(reader, limits) (Input you do not control).
  • Exception classes. SyntaxErrorException, SemanticErrorException, MaximumRecursionLevelReachedException and AnchorNotFoundException are raised, but which class a given error gets can differ from YamlDotNet. Catch YamlException if you can.
  • Line breaks. The serializer writes the platform line break, as YamlDotNet does.

Input you do not control

The adapter adds three members that YamlDotNet does not have, so that reading can keep to the limits of the OneYaml library: DeserializerBuilder.WithLimits(YamlLimits), YamlStream.Load(TextReader, YamlLimits) and new Parser(TextReader, YamlLimits). Without them the adapter reads with YamlLimits.Default. For uploads, request bodies and files from other systems, pass YamlLimits.Untrusted, which also bounds the input size, the size of a scalar and the number of documents. YamlLimits is in the OneYaml namespace; import it with using YamlLimits = OneYaml.YamlLimits;, not using OneYaml;, because that namespace has its own YamlException, YamlDocument, YamlNode and YamlIgnoreAttribute, and importing it beside the adapter's namespaces makes those names ambiguous (error CS0104).

C#
using OneYaml.Compat.YamlDotNet.Core;
using OneYaml.Compat.YamlDotNet.Serialization;
using YamlLimits = OneYaml.YamlLimits;
 
string upload = new string('[', 129) + new string(']', 129); // nested 129 deep
 
// WithLimits is not in YamlDotNet. It takes the limits of the OneYaml library; Untrusted is its preset for outside input.
IDeserializer deserializer = new DeserializerBuilder()
    .WithLimits(YamlLimits.Untrusted)
    .Build();
try
{
    deserializer.Deserialize<object>(upload);
}
catch (YamlException e)
{
    Console.WriteLine(e.Message); // The nesting depth is larger than the limit MaxDepth (128). (line 1, column 129)
}
 
// The default limits allow the same text.
Console.WriteLine(new DeserializerBuilder().Build().Deserialize<object>(upload) is not null); // True

Code that uses these members no longer compiles against YamlDotNet itself.

Move to the OneYaml API

Terminal
dotnet add package OneYaml

If the project still references OneYaml.Compat.YamlDotNet, keep OneYaml at the same version: the adapter depends on that exact version.

C#
using OneYaml;
 
// camelCase keys and skipping unknown keys are the defaults; no builder is needed.
var service = YamlSerializer.Deserialize<Service>("name: web\nreplicas: 2\nlegacy: true\n")!;
Console.Write(YamlSerializer.Serialize(service));
// name: web
// replicas: 2
 
class Service
{
    public string Name { get; set; } = "";
    public int Replicas { get; set; }
}
YamlDotNetOneYaml
new DeserializerBuilder().Build().Deserialize<T>(yaml)YamlSerializer.Deserialize<T>(yaml)
new SerializerBuilder().Build().Serialize(value)YamlSerializer.Serialize(value)
WithNamingConvention(CamelCaseNamingConvention.Instance)the default (PropertyNamingPolicy = JsonNamingPolicy.CamelCase)
UnderscoredNamingConvention, HyphenatedNamingConventionJsonNamingPolicy.SnakeCaseLower, JsonNamingPolicy.KebabCaseLower
NullNamingConvention (YamlDotNet's default)PropertyNamingPolicy = null
IgnoreUnmatchedProperties()the default; UnmappedMemberHandling = Disallow is YamlDotNet's default behaviour
WithCaseInsensitivePropertyMatching()PropertyNameCaseInsensitive = true
WithDuplicateKeyChecking()always on: a repeated key is an error
WithMaximumRecursion(n)Limits = YamlLimits.Default with { MaxDepth = n }
ConfigureDefaultValuesHandling(OmitNull)DefaultIgnoreCondition = YamlIgnoreCondition.WhenWritingNull
WithTypeConverter(IYamlTypeConverter)Converters = { new MyConverter() }, a YamlConverter<T> (Converters)
WithTagMapping("!x", typeof(X))[YamlPolymorphic(DiscriminatorKind = YamlDiscriminatorKind.Tag)] and [YamlDerivedType(typeof(X), "x")] on a base type (Polymorphism)
[YamlMember(Alias = "x")], [YamlMember(Order = n)][YamlPropertyName("x")], [YamlPropertyOrder(n)]
[YamlIgnore][YamlIgnore] (in the OneYaml namespace)
MergingParserMergeKeys = true
YamlStream, YamlMappingNode, YamlScalarNodeYamlDocument, YamlNode (Editing)
YamlException.StartYamlException.Line, Column, Offset, plus Category and Path
static builders with a separate generator package[YamlSerializable] on a YamlSerializerContext; the generator is in the package (Source generation)

Behaviour to expect when you move:

  • YAML 1.2, not 1.1. yes, on and y are strings, so reading enabled: yes into a bool is an error; write true. 017 is 17, not octal 15; 0o17 is 15.
  • Untyped values have types. Deserialize<object> gives long, double, bool and null for plain scalars, where YamlDotNet gives strings (Untyped values).
  • Repeated keys are an error, where YamlDotNet keeps the last one by default.
  • null members are written as null unless you set DefaultIgnoreCondition.

Editing a file

YamlDotNet's representation model writes a file again from the model, so comments and blank lines are lost when you save a change. YamlDocument changes only the value you set:

C#
// Instead of YamlStream: load, change one value, save, and keep the comments.
using var doc = YamlDocument.Parse("# service settings\nname: web\nreplicas: 2   # set by CI\n");
doc.Root["replicas"] = 3;
Console.Write(doc);
// # service settings
// name: web
// replicas: 3   # set by CI

See Editing for pointers, styles and aliases, and Patches to apply a JSON Patch.