Skip to content

Commit

Permalink
feat: Reject DateTimes in deserialization that have no explicit offset (
Browse files Browse the repository at this point in the history
  • Loading branch information
hf-kklein authored Oct 27, 2024
1 parent 48e3991 commit cb1eb3b
Show file tree
Hide file tree
Showing 6 changed files with 160 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System;
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace MaLoIdentModels.JsonSettings;

/// <summary>
/// helper methods for handling strings that represent datetimes
/// </summary>
internal static class DateTimeOffsetStringExtensionMethods
{
private const string FormatWithoutMilliseconds = "yyyy-MM-ddTHH:mm:ssK";
private const string FormatWithMilliseconds = "yyyy-MM-ddTHH:mm:ss.fffK";
private const string FormatWithMicroseconds = "yyyy-MM-ddTHH:mm:ss.ffffffK";

private static readonly string[] SupportedFormats =
[
FormatWithoutMilliseconds,
FormatWithMilliseconds,
FormatWithMicroseconds,
];

/// <summary>
/// simple regex to check if a string ends with something that looks like a timezone or UTC offset.
/// </summary>
/// <remarks>
/// Note that this isn't super strict, e.g. '+17:99' would also pass, but that's fine,
/// because the actual parsing happens with regular DateTime parsing.
/// </remarks>
private static readonly Regex EndsWithOffsetPattern = new(@"^.+[\+\-]\d{1,2}:\d{2}$");

/// <summary>
/// converts the given <paramref name="dtoString"/> to an datetimeoffset
/// </summary>
/// <param name="dtoString"></param>
/// <returns></returns>
/// <exception cref="JsonException">if no explicit offset is specified in <paramref name="dtoString"/></exception>
internal static DateTimeOffset ToDateTimeOffset(this string dtoString)
{
if (!(dtoString.EndsWith("Z") || EndsWithOffsetPattern.Match(dtoString).Success))
{
throw new JsonException(
$"DateTimeOffset value must have an explicit offset, like 'Z' or '+/-HH:mm' but '{dtoString}' doesn't"
);
}

if (
!DateTimeOffset.TryParseExact(
dtoString,
SupportedFormats,
null,
DateTimeStyles.None,
out var result
)
)
{
throw new JsonException(
$"DateTimeOffset value must match any of the supported formats: {string.Join(", ", SupportedFormats)}, but '{dtoString}' doesn't"
);
}

return new DateTimeOffset(result.UtcDateTime);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;

Expand All @@ -7,6 +8,7 @@ namespace MaLoIdentModels.JsonSettings;
/// <summary>
/// A JsonConverter that ensures, that we serialize DateTimeOffsets with the "Z" suffix as required by Edi@Enery.
/// A plain '+00:00' is not sufficient.
/// For DE-serializing this converter is more lenient: It allows the use of any explicit UTC offset and supports optional milli and microseconds.
/// </summary>
public class DateTimeOffsetWithTrailingZConverter : JsonConverter<DateTimeOffset>
{
Expand All @@ -16,7 +18,8 @@ public override DateTimeOffset Read(
JsonSerializerOptions options
)
{
return DateTimeOffset.Parse(reader.GetString()!);
var dateTimeString = reader.GetString()!;
return dateTimeString.ToDateTimeOffset();
}

public override void Write(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;

Expand All @@ -20,9 +21,8 @@ JsonSerializerOptions options
{
return null;
}

// Parse the input string to a DateTimeOffset
return DateTimeOffset.Parse(reader.GetString()!);
var dateTimeString = reader.GetString()!;
return dateTimeString.ToDateTimeOffset();
}

public override void Write(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System.Text.Json;
using FluentAssertions;
using MaLoIdentModels;

namespace MaLoIdentModelsTests;

public class DateTimeOffsetDeserializationTests
{
/// <summary>
/// We want to avoid, that clients can send us serialized datetime(offsets), with no explicit offset given.
/// So '2025-01-01T00:00:00Z' is fine but '2025-01-01T00:00:00' (implicit or no offset) is not.
/// Also, we'll accept '2025-01-01T00:00:00+00:00' and '2025-01-01T01:00:00+01:00'.
/// Both the latter are not "officially" allowed, but we think that providing any UTC offset is good enough,
/// instead of the BDEW interpretation that "only UTC offset 0 indicated only by trailing Z" were ok.
/// This is because we can convert any date+time+offset to UTC, even if the offset is != 0.
/// </summary>
[Theory]
[InlineData("2023-08-02T22:00:00")]
[InlineData("2023-08-02T22:00:00+17:99")]
public void Deserializing_An_DatetimeOffset_Without_Explicit_Offset_Throws_JsonException(
string invalidDateTimeOffsetString
)
{
var fileBody = File.ReadAllText("examples/request.json");
fileBody
.Should()
.Contain(
"\"2023-08-02T22:00:00Z\"",
because: "The original example data use trailing 'Z' to indicate UTC offset 0"
);
var requestJsonWithoutExplicitUtcOffset = fileBody.Replace(
"\"2023-08-02T22:00:00Z\"",
$"\"{invalidDateTimeOffsetString}\""
);
var deserializing = () =>
JsonSerializer.Deserialize<IdentificationParameter>(
requestJsonWithoutExplicitUtcOffset
);
deserializing.Should().Throw<JsonException>();
}

/// <summary>
/// We want to avoid, that clients can send us serialized datetime(offsets), with no explicit offset given.
/// So '2025-01-01T00:00:00Z' is fine but '2025-01-01T00:00:00' (implicit or no offset) is not.
/// Also, we'll accept '2025-01-01T00:00:00+00:00' and '2025-01-01T01:00:00+01:00'.
/// Both the latter are not "officially" allowed, but we think that providing any UTC offset is good enough,
/// instead of the BDEW interpretation that "only UTC offset 0 indicated only by trailing Z" were ok.
/// This is because we can convert any date+time+offset to UTC, even if the offset is != 0.
/// </summary>
[Theory]
[InlineData("2023-08-02T22:00:00Z")]
[InlineData("2023-08-02T22:00:00+00:00")]
[InlineData("2023-08-03T00:00:00+02:00")]
[InlineData("2023-08-02T20:00:00-02:00")]
[InlineData("2023-08-03T00:00:00.000+02:00")]
[InlineData("2023-08-03T00:00:00.000000+02:00")]
public void Deserializing_An_DatetimeOffset_With_Explicit_Offset_Works(
string dateTimeOffsetString
)
{
var fileBody = File.ReadAllText("examples/request.json");
fileBody
.Should()
.Contain(
"\"2023-08-02T22:00:00Z\"",
because: "The original example data use trailing 'Z' to indicate UTC offset 0"
);
var requestJsonWithoutExplicitUtcOffset = fileBody.Replace(
"\"2023-08-02T22:00:00Z\"",
$"\"{dateTimeOffsetString}\""
);
var deserializing = () =>
JsonSerializer.Deserialize<IdentificationParameter>(
requestJsonWithoutExplicitUtcOffset
);
deserializing
.Should()
.NotThrow<JsonException>()
.And.Subject.Invoke()
.IdentificationDateTime.Should()
.Be(new DateTimeOffset(2023, 8, 2, 22, 0, 0, 0, TimeSpan.Zero));
}
}
3 changes: 3 additions & 0 deletions MaLoIdentModels/MaLoIdentModelsTests/RoundTripTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public void Test_Request()
var fileBody = File.ReadAllText("examples/request.json");
var model = System.Text.Json.JsonSerializer.Deserialize<IdentificationParameter>(fileBody);
model.Should().NotBeNull();
{
model!.IdentificationDateTime.Offset.Should().Be(TimeSpan.Zero);
}
var reSererialized = System.Text.Json.JsonSerializer.Serialize(model);
Utilities.AssertJsonStringsAreEquivalent(fileBody, reSererialized);
var deserialized = System.Text.Json.JsonSerializer.Deserialize<IdentificationParameter>(
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@

This repository contains the nuget package `MaLoIdentModels` which contains C# model classes with `System.Text.Json` attributes for the Marktlokation Identification API by EDI@Energy.

It (de)serializes model classes to 100% as required by EDI@Energy (this includes datetimes and enums), but provides you as a developer with strongly typed models instead of stringly typed properties that you'd have to deal with. if you used the offical OpenApi spec.
It (de)serializes model classes to 100% as required by EDI@Energy (this includes datetimes and enums), but provides you as a developer with strongly typed models instead of stringly typed properties that you'd have to deal with, if you used the official OpenApi spec.

All the JSON serialization settings come out of the box, no manual settings required as long as you use STJ.
All the JSON serialization settings come out of the box, no manual settings required as long as you use `System.Text.Json`.

## Installation and Use

Expand Down

0 comments on commit cb1eb3b

Please sign in to comment.