Initial commit (history squashed)
This commit is contained in:
161
src/StellaOps.Feedser.Models/CanonicalJsonSerializer.cs
Normal file
161
src/StellaOps.Feedser.Models/CanonicalJsonSerializer.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace StellaOps.Feedser.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic JSON serializer tuned for canonical advisory output.
|
||||
/// </summary>
|
||||
public static class CanonicalJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
|
||||
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
|
||||
|
||||
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrderOverrides = new Dictionary<Type, string[]>
|
||||
{
|
||||
{
|
||||
typeof(AdvisoryProvenance),
|
||||
new[]
|
||||
{
|
||||
"source",
|
||||
"kind",
|
||||
"value",
|
||||
"decisionReason",
|
||||
"recordedAt",
|
||||
"fieldMask",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(AffectedPackage),
|
||||
new[]
|
||||
{
|
||||
"type",
|
||||
"identifier",
|
||||
"platform",
|
||||
"versionRanges",
|
||||
"normalizedVersions",
|
||||
"statuses",
|
||||
"provenance",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(AdvisoryCredit),
|
||||
new[]
|
||||
{
|
||||
"displayName",
|
||||
"role",
|
||||
"contacts",
|
||||
"provenance",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NormalizedVersionRule),
|
||||
new[]
|
||||
{
|
||||
"scheme",
|
||||
"type",
|
||||
"min",
|
||||
"minInclusive",
|
||||
"max",
|
||||
"maxInclusive",
|
||||
"value",
|
||||
"notes",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, CompactOptions);
|
||||
|
||||
public static string SerializeIndented<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, PrettyOptions);
|
||||
|
||||
public static Advisory Normalize(Advisory advisory)
|
||||
=> new(
|
||||
advisory.AdvisoryKey,
|
||||
advisory.Title,
|
||||
advisory.Summary,
|
||||
advisory.Language,
|
||||
advisory.Published,
|
||||
advisory.Modified,
|
||||
advisory.Severity,
|
||||
advisory.ExploitKnown,
|
||||
advisory.Aliases,
|
||||
advisory.Credits,
|
||||
advisory.References,
|
||||
advisory.AffectedPackages,
|
||||
advisory.CvssMetrics,
|
||||
advisory.Provenance);
|
||||
|
||||
public static T Deserialize<T>(string json)
|
||||
=> JsonSerializer.Deserialize<T>(json, PrettyOptions)!
|
||||
?? throw new InvalidOperationException($"Unable to deserialize type {typeof(T).Name}.");
|
||||
|
||||
private static JsonSerializerOptions CreateOptions(bool writeIndented)
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
WriteIndented = writeIndented,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
|
||||
options.TypeInfoResolver = new DeterministicTypeInfoResolver(baselineResolver);
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
|
||||
return options;
|
||||
}
|
||||
|
||||
private sealed class DeterministicTypeInfoResolver : IJsonTypeInfoResolver
|
||||
{
|
||||
private readonly IJsonTypeInfoResolver _inner;
|
||||
|
||||
public DeterministicTypeInfoResolver(IJsonTypeInfoResolver inner)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
}
|
||||
|
||||
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
|
||||
{
|
||||
var info = _inner.GetTypeInfo(type, options);
|
||||
if (info is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
|
||||
}
|
||||
|
||||
if (info.Kind is JsonTypeInfoKind.Object && info.Properties is { Count: > 1 })
|
||||
{
|
||||
var ordered = info.Properties
|
||||
.OrderBy(property => GetPropertyOrder(type, property.Name))
|
||||
.ThenBy(property => property.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
info.Properties.Clear();
|
||||
foreach (var property in ordered)
|
||||
{
|
||||
info.Properties.Add(property);
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private static int GetPropertyOrder(Type type, string propertyName)
|
||||
{
|
||||
if (PropertyOrderOverrides.TryGetValue(type, out var order) &&
|
||||
Array.IndexOf(order, propertyName) is var index &&
|
||||
index >= 0)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user