Skip to content

Commit

Permalink
[Codegen] Allow any nullable double fields to be strings or doubles a…
Browse files Browse the repository at this point in the history
…nd improved error reporting (#984)

* Allow any nullable double fields to be strings or doubles and improved error reporting

* small fix
  • Loading branch information
FrankBakkerNl authored Nov 3, 2023
1 parent 22039b7 commit dfd1684
Show file tree
Hide file tree
Showing 7 changed files with 199 additions and 61 deletions.
38 changes: 31 additions & 7 deletions src/HassModel/NetDaemon.HassModel.CodeGenerator/Controller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ public Controller(CodeGenerationSettings generationSettings, HomeAssistantSettin
private string EntityMetaDataFileName => Path.Combine(OutputFolder, "EntityMetaData.json");
private string ServicesMetaDataFileName => Path.Combine(OutputFolder, "ServicesMetaData.json");

private string OutputFolder => string.IsNullOrEmpty(_generationSettings.OutputFolder)
? Directory.GetParent(Path.GetFullPath(_generationSettings.OutputFile))!.FullName
private string OutputFolder => string.IsNullOrEmpty(_generationSettings.OutputFolder)
? Directory.GetParent(Path.GetFullPath(_generationSettings.OutputFile))!.FullName
: _generationSettings.OutputFolder;

public async Task RunAsync()
{
var (hassStates, servicesMetaData) = await HaRepositry.GetHaData(_haSettings).ConfigureAwait(false);
Expand All @@ -38,11 +38,35 @@ public async Task RunAsync()
await Save(mergedEntityMetaData, EntityMetaDataFileName).ConfigureAwait(false);
await Save(servicesMetaData, ServicesMetaDataFileName).ConfigureAwait(false);

var generatedTypes = Generator.GenerateTypes(mergedEntityMetaData.Domains, ServiceMetaDataParser.Parse(servicesMetaData!.Value));
var hassServiceDomains = ServiceMetaDataParser.Parse(servicesMetaData!.Value, out var deserializationErrors);
CheckParseErrors(deserializationErrors);

Check warning on line 42 in src/HassModel/NetDaemon.HassModel.CodeGenerator/Controller.cs

View check run for this annotation

Codecov / codecov/patch

src/HassModel/NetDaemon.HassModel.CodeGenerator/Controller.cs#L41-L42

Added lines #L41 - L42 were not covered by tests

var generatedTypes = Generator.GenerateTypes(mergedEntityMetaData.Domains, hassServiceDomains);

Check warning on line 44 in src/HassModel/NetDaemon.HassModel.CodeGenerator/Controller.cs

View check run for this annotation

Codecov / codecov/patch

src/HassModel/NetDaemon.HassModel.CodeGenerator/Controller.cs#L44

Added line #L44 was not covered by tests

SaveGeneratedCode(generatedTypes);
}

internal static void CheckParseErrors(List<DeserializationError> parseErrors)
{
if (parseErrors.Count == 0) return;

Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("""
Errors occured while parsing metadata from Home Assistant for one or more services.
This is usually caused by metadata from HA that is not in the expected JSON format.
nd-codegen will try to continue to generate code for other services.
""");
Console.ResetColor();
foreach (var deserializationError in parseErrors)
{
Console.WriteLine();
Console.WriteLine(deserializationError.Exception);
Console.WriteLine(deserializationError.Context + " = ");
Console.Out.Flush();
Console.WriteLine(JsonSerializer.Serialize(deserializationError.Element, new JsonSerializerOptions{WriteIndented = true}));
}
}

internal async Task<EntitiesMetaData> LoadEntitiesMetaDataAsync()
{
var fileStream = File.Exists(EntityMetaDataFileName) switch
Expand Down Expand Up @@ -72,15 +96,15 @@ private async Task Save<T>(T merged, string fileName)
await using var _ = fileStream.ConfigureAwait(false);
await JsonSerializer.SerializeAsync(fileStream, merged, JsonSerializerOptions).ConfigureAwait(false);
}

private static JsonSerializerOptions JsonSerializerOptions =>
new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
Converters = { new ClrTypeJsonConverter() }
};

private void SaveGeneratedCode(MemberDeclarationSyntax[] generatedTypes)
{
if (!_generationSettings.GenerateOneFilePerEntity)
Expand Down Expand Up @@ -112,4 +136,4 @@ private void SaveGeneratedCode(MemberDeclarationSyntax[] generatedTypes)
Console.WriteLine(OutputFolder);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class SelectorConverter : JsonConverter<Selector>
return new Selector { Type = selectorName};
}

var deserialize = (Selector?)element.Deserialize(selectorType, ServiceMetaDataParser.SnakeCaseNamingPolicySerializerOptions);
var deserialize = (Selector?)element.Deserialize(selectorType, ServiceMetaDataParser.SerializerOptions);
deserialize ??= (Selector)Activator.CreateInstance(selectorType)!;

return deserialize with { Type = selectorName };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,11 @@ internal record EntitySelector : Selector

internal record NumberSelector : Selector
{
[Required]
public double Min { get; init; }
public double? Min { get; init; }

[Required]
public double Max { get; init; }
public double? Max { get; init; }


// Step can also contain the string "any" which is not usefull for our purpose, se we deserialize as a string and then try to parse as a double
[JsonPropertyName("step")]
public string? StepValue { get; init; }

[JsonIgnore]
public double? Step => double.TryParse(StepValue, out var d) ? d: null;
public double? Step { get; init; }

public string? UnitOfMeasurement { get; init; }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,76 +1,86 @@
namespace NetDaemon.HassModel.CodeGenerator.Model;

public record DeserializationError(Exception Exception, string? Context, JsonElement Element);

internal static class ServiceMetaDataParser
{
public static readonly JsonSerializerOptions SnakeCaseNamingPolicySerializerOptions = new()

public static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = SnakeCaseNamingPolicy.Instance
PropertyNamingPolicy = SnakeCaseNamingPolicy.Instance,
Converters = { new StringAsDoubleConverter() }
};


public static IReadOnlyCollection<HassServiceDomain> Parse(JsonElement element) => Parse(element, out _);

Check warning on line 14 in src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/ServiceMetaDataParser.cs

View check run for this annotation

Codecov / codecov/patch

src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/ServiceMetaDataParser.cs#L14

Added line #L14 was not covered by tests

/// <summary>
/// Parses all json elements to instance result from GetServices call
/// </summary>
/// <param name="element">JsonElement containing the result data</param>
/// <param name="errors">Outputs Any Exceptions during deserialization</param>
public static IReadOnlyCollection<HassServiceDomain> Parse(JsonElement element, out List<Exception> errors)
public static IReadOnlyCollection<HassServiceDomain> Parse(JsonElement element, out List<DeserializationError> errors)
{
errors = new List<Exception>();
errors = new List<DeserializationError>();
if (element.ValueKind != JsonValueKind.Object)
throw new InvalidOperationException("Not expected result from the GetServices result");

var hassServiceDomains = new List<HassServiceDomain>();
foreach (var property in element.EnumerateObject())
foreach (var domainProperty in element.EnumerateObject())
{
try
{
var hassServiceDomain = new HassServiceDomain
{
Domain = property.Name,
Services = GetServices(property.Value)
Domain = domainProperty.Name,
Services = GetServices(domainProperty.Value, errors, domainProperty.Name)
};
hassServiceDomains.Add(hassServiceDomain);
}
catch (JsonException e)
{
Console.Error.WriteLine($"JSON deserialization of {nameof(HassServiceDomain)} failed: {e.Message}");
Console.Error.WriteLine($"Deserialization source was: {property.Value}");
errors.Add(e);
errors.Add(new (e, domainProperty.Name, domainProperty.Value));

Check warning on line 41 in src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/ServiceMetaDataParser.cs

View check run for this annotation

Codecov / codecov/patch

src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/ServiceMetaDataParser.cs#L41

Added line #L41 was not covered by tests
}
}
return hassServiceDomains;
}

private static IReadOnlyCollection<HassService> GetServices(JsonElement element)
private static IReadOnlyCollection<HassService> GetServices(JsonElement domainElement, List<DeserializationError> errors, string context)
{
return element.EnumerateObject()
return domainElement.EnumerateObject()
.Select(serviceDomainProperty =>
GetService(serviceDomainProperty.Name, serviceDomainProperty.Value)).ToList();
}
GetService(serviceDomainProperty.Name, serviceDomainProperty.Value, errors, context))
.OfType<HassService>().ToList();
}

private static HassService GetService(string service, JsonElement element)
private static HassService? GetService(string serviceName, JsonElement serviceElement, List<DeserializationError> errors, string context)
{
var result = element.Deserialize<HassService>(SnakeCaseNamingPolicySerializerOptions)! with
try
{
Service = service,
};

if (element.TryGetProperty("fields", out var fieldProperty))
{
result = result with
var result = serviceElement.Deserialize<HassService>(SerializerOptions)! with
{
Fields = fieldProperty.EnumerateObject().Select(p => GetField(p.Name, p.Value)).ToList()
Service = serviceName,
};
}

return result;
if (serviceElement.TryGetProperty("fields", out var fieldProperty))
{
result = result with
{
Fields = fieldProperty.EnumerateObject().Select(p => GetField(p.Name, p.Value)).ToList()
};
}

return result;
}
catch (Exception ex)
{
errors.Add(new (ex, $"{context}.{serviceName}", serviceElement));
return null;
}
}

private static HassServiceField GetField(string fieldName, JsonElement element)
{
return element.Deserialize<HassServiceField>(SnakeCaseNamingPolicySerializerOptions)! with
return element.Deserialize<HassServiceField>(SerializerOptions)! with
{
Field = fieldName,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Text.Json.Serialization;

namespace NetDaemon.HassModel.CodeGenerator.Model;

class StringAsDoubleConverter : JsonConverter<double?>
{
public override double? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Some fields (step) can have a string or a numeric value. If it is a string we will try to parse it to a decimal
return reader.TokenType switch
{
JsonTokenType.Number => reader.GetDouble(),
JsonTokenType.String => double.TryParse(reader.GetString(), out var d) ? d : null,
_ => Skip(ref reader)

Check warning on line 14 in src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/StringAsDoubleConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/StringAsDoubleConverter.cs#L14

Added line #L14 was not covered by tests
};
}

double? Skip(ref Utf8JsonReader reader)

Check warning on line 18 in src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/StringAsDoubleConverter.cs

View workflow job for this annotation

GitHub Actions / 📦 publish nuget packages

Member 'Skip' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
{
reader.Skip();
return null;

Check warning on line 21 in src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/StringAsDoubleConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/StringAsDoubleConverter.cs#L20-L21

Added lines #L20 - L21 were not covered by tests
}

public override void Write(Utf8JsonWriter writer, double? value, JsonSerializerOptions options) => throw new NotSupportedException();

Check warning on line 24 in src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/StringAsDoubleConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/StringAsDoubleConverter.cs#L24

Added line #L24 was not covered by tests
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Text.Json;
using NetDaemon.HassModel.CodeGenerator;
using NetDaemon.HassModel.CodeGenerator.Model;

namespace NetDaemon.HassModel.Tests.CodeGenerator;
Expand Down Expand Up @@ -143,7 +144,6 @@ public void DeserializeTargetEntityArray()

}


[Fact]
public void NumericStepCanBeAny()
{
Expand Down Expand Up @@ -183,18 +183,30 @@ public void NumericStepCanBeAny()
"name":"Longitude",
"description":"Longitude of your location."
},
"elevation":{
"required":false,
"example":120,
"selector":{
"number":{
"mode":"box",
"step":"1"
}
},
"name":"Elevation",
"description":"Elevation of your location."
}
"elevation":{
"required":false,
"example":120,
"selector":{
"number":{
"mode":"box",
"step":"1"
}
},
"name":"Elevation",
"description":"Elevation of your location."
},
"testNumberStep":{
"required":false,
"example":120,
"selector":{
"number":{
"mode":"box",
"step":0.01
}
},
"name":"Elevation",
"description":"Elevation of your location."
}
}
}
}
Expand All @@ -203,7 +215,82 @@ public void NumericStepCanBeAny()
var result = Parse(sample);

var steps = result.Single().Services.Single().Fields!.Select(f => (f.Selector as NumberSelector)!.Step).ToArray();
steps.Should().Equal(null, null, 1); // any is mapped to null
steps.Should().Equal(null, null, 1, 0.01d); // any is mapped to null
}


[Fact]
public void JsonError()
{
var sample = """
{
"orbiter_services": {
"invalid_json_service": {
"name": "Observe Planet",
"description": ["Array is not allowed here!"],
"fields": {
"frequency": {
"required": 1.1,
"example": false,
"selector": {
"number": {
"multiple": false,
"mode": "box",
"min": "N/A",
"max": "N/A",
"step": "any"
}
}
}
}
},
"navigate": {
"name": "Navigates to a new location",
"fields": {
"latitude": {
"required": true,
"example": 32.87336,
"selector": {
"number": {
"mode": 212,
"min": -90,
"max": 90,
"step": "any"
}
},
"name": "Latitude",
"description": "Latitude of your location."
},
"longitude": {
"required": true,
"example": 117.22743,
"selector": {
"number": {
"mode": "box",
"min": -180,
"max": 180,
"step": "any"
}
},
"name": "Longitude",
"description": "Longitude of your location."
}
}
}
}
}
""";
var element = JsonDocument.Parse(sample).RootElement;
var result = ServiceMetaDataParser.Parse(element, out var errors);

errors.Should().HaveCount(1, because: "We should get an error for the failed service");
errors.Single().Context.Should().Be("orbiter_services.invalid_json_service");

result.Should().HaveCount(1, because:"The service that is valid should still be parsed ");
result.Single().Services.Should().HaveCount(1);

// Just to manually validate the console output while running in the in the IDE
Controller.CheckParseErrors(errors);
}

private static IReadOnlyCollection<HassServiceDomain> Parse(string sample)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public void TestServicesGeneration()
Service = "turn_on",
Fields = new HassServiceField[] {
new() { Field = "transition", Selector = new NumberSelector(), },
new() { Field = "brightness", Selector = new NumberSelector { StepValue = "0.2" }, }
new() { Field = "brightness", Selector = new NumberSelector { Step = 0.2d }, }
},
Target = new TargetSelector
{
Expand Down

0 comments on commit dfd1684

Please sign in to comment.