aaubry / YamlDotNet

YamlDotNet is a .NET library for YAML
MIT License
2.48k stars 466 forks source link

How to forcefully interpret as an array with implied key #830

Closed timheuer closed 10 months ago

timheuer commented 11 months ago

I'm trying to parse a GitHub Actions workflow file...here's a snippet of valid workflow:

name: "Build"

on:
  workflow_dispatch:
    inputs:
      reason:
        description: The reason for the manual run
        required: true
        type: string
      something_else:
        description: Some other input
        required: false
        type: boolean

The inputs is not resolving to an array, but rather properties of inputs. If I change these to -reason etc then it sees them as an array, but that's not valid workflow syntax.

I'm curious if there is a way I can get the parser to see this as an array where the reason portion would be the key?

EdwardCooke commented 11 months ago

The reasons it’s not coming in as an array is because it isn’t an array. It’s a mapping. Which means it will be properties or key/value pairs. You could probably create your own node deserializer or type converter to handle that part of your yaml differently.

You can see here what the different parts of your yaml are with this cool tool. It’s what we use to determine the validity of yaml and one of the tools we use to make sure we’re parsing things correctly.

http://ben-kiki.org/ypaste/data/78777/index.html

tymokvo commented 11 months ago

I think the short answer to your question is "no" since arrays don't have string keys (like reason) but you could make a type/named tuple that can hold the key and value from the mapping in one value.

It seems a lot easier to deserialize the way the library intends and then just implement a method on your type that returns the array-ified (key, value) pairs. I made a quick version with F#, but the C# version should be broadly similar. Except more verbose 😉.

Some gross F# code ```fsharp #r "nuget: YamlDotNet" open YamlDotNet.Serialization open System.IO type Input() = member val description = "" with get, set member val required = false with get, set [] member val kind = "" with get, set member z.toRecord = {| description = z.description required = z.required kind = z.kind |} type WorkflowDispatch() = member val inputs: System.Collections.Generic.Dictionary = System.Collections.Generic.Dictionary() with get, set member z.inputsArray = z.inputs.Keys |> Seq.map (fun k -> {| name = k value = z.inputs[k].toRecord |}) |> Seq.toArray type On() = member val workflowDispatch = WorkflowDispatch() with get, set type GHAction() = member val name = "" with get, set member val on = On() with get, set member z.inputsArray = z.on.workflowDispatch.inputsArray /// Create a deserializer for a YAML file let deserializer _ = DeserializerBuilder() .WithNamingConvention(NamingConventions.UnderscoredNamingConvention.Instance) .Build() let deserialize<'t> (content: string) = content |> (() |> deserializer).Deserialize<'t> File.ReadAllText("ghaction.yml") |> deserialize |> (fun gha -> gha.inputsArray) |> printfn "%A" ```

result:

[| { name = "reason"
     value =
       { description = "The reason for the manual run"
         kind = "string"
         required = true } }
   { name = "something_else"
     value =
       { description = "Some other input"
         kind = "boolean"
         required = false } } |]
EdwardCooke commented 10 months ago

Ok, spent some time on this, you can use a custom INodeDeserializer to convert it into an array. I set the value to the Key property on the object. Here's the code.

Note, it does not deserialize to the same yaml, if you need that, let me know and I could try and put something together for that.

using YamlDotNet.Core;
using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;

var yaml = @"name: ""Build""
on:
  workflow_dispatch:
    inputs:
      reason:
        description: The reason for the manual run
        required: true
        type: string
      something_else:
        description: Some other input
        required: false
        type: boolean
";
var deserializer = new DeserializerBuilder().WithNodeDeserializer(new InputObjectNodeDeserializer(), (syntax) => syntax.OnTop()).Build();

var action = deserializer.Deserialize<Actions>(yaml);
foreach (var input in action.On.WorkflowDispatch.Inputs)
{
    Console.WriteLine("{0} - {1}", input.Key, input.Description);
}

public class Actions
{
    [YamlMember(Alias = "name")]
    public string Name { get; set; }

    [YamlMember(Alias = "on")]
    public Dispatch On { get; set; }

}

public class Dispatch
{
    [YamlMember(Alias = "workflow_dispatch")]
    public WorkflowDispatch WorkflowDispatch { get; set; }
}

public class WorkflowDispatch
{
    [YamlMember(Alias = "inputs")]
    public Input[] Inputs { get; set; }
}

public class Input
{
    [YamlMember(Alias = "key")]
    public string Key { get; set; }

    [YamlMember(Alias = "description")]
    public string Description { get; set; }

    [YamlMember(Alias = "required")]
    public bool Required { get; set; }

    [YamlMember(Alias = "type")]
    public string Type { get; set; }
}

public class InputObjectNodeDeserializer : INodeDeserializer
{
    public bool Deserialize(IParser reader, Type expectedType, Func<IParser, Type, object?> nestedObjectDeserializer, out object? value)
    {
        if (expectedType != typeof(Input[]))
        {
            value = null;
            return false;
        }

        if (!reader.Accept<MappingStart>())
        {
            value = null;
            return false;
        }

        reader.Consume<MappingStart>();
        var result = new List<Input>();
        while (!reader.TryConsume<MappingEnd>(out var _))
        {
            var keyScalar = reader.Consume<Scalar>();
            var input = nestedObjectDeserializer(reader, typeof(Input)) as Input;
            if (input != null)
            {
                input.Key = keyScalar.Value;
            }
            result.Add(input);
        }

        value = result.ToArray();
        return true;
    }
}

The output

reason - The reason for the manual run
something_else - Some other input

I'm going to close this issue since this is an example of doing what you want, re-open if you need serialization too.