| | | 1 | | using System.Text; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | using ProjectTemplate.Infrastructure.Data.Entities; |
| | | 4 | | |
| | | 5 | | namespace ProjectTemplate.Infrastructure.Data.Auditing; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// Builds deterministic version 1.0 mutation manifests using ordinal ordering rules. |
| | | 9 | | /// </summary> |
| | | 10 | | public sealed class CanonicalApplicationMutationManifestBuilder : IApplicationMutationManifestBuilder |
| | | 11 | | { |
| | | 12 | | /// <inheritdoc /> |
| | | 13 | | public ApplicationMutationManifest Build(IReadOnlyCollection<AuditRecord> auditRecords) |
| | | 14 | | { |
| | 84 | 15 | | ArgumentNullException.ThrowIfNull(auditRecords); |
| | | 16 | | |
| | 84 | 17 | | if (auditRecords.Count == 0) |
| | | 18 | | { |
| | 0 | 19 | | throw new ArgumentException("At least one audit record is required to build a mutation manifest.", nameof(au |
| | | 20 | | } |
| | | 21 | | |
| | 84 | 22 | | string mutationBatchId = auditRecords.First().MutationBatchId; |
| | 84 | 23 | | if (string.IsNullOrWhiteSpace(mutationBatchId)) |
| | | 24 | | { |
| | 0 | 25 | | throw new InvalidOperationException("Mutation audit records require a mutation batch identifier."); |
| | | 26 | | } |
| | | 27 | | |
| | 184 | 28 | | if (auditRecords.Any(record => !string.Equals(record.MutationBatchId, mutationBatchId, StringComparison.Ordinal) |
| | | 29 | | { |
| | 0 | 30 | | throw new InvalidOperationException("A canonical mutation manifest can represent only one mutation batch."); |
| | | 31 | | } |
| | | 32 | | |
| | 84 | 33 | | var canonicalRecords = auditRecords |
| | 84 | 34 | | .Select(CreateCanonicalRecord) |
| | 30 | 35 | | .OrderBy(record => record.Entity, StringComparer.Ordinal) |
| | 30 | 36 | | .ThenBy(record => record.State, StringComparer.Ordinal) |
| | 30 | 37 | | .ThenBy(record => record.KeyValues, StringComparer.Ordinal) |
| | 30 | 38 | | .ThenBy(record => record.OriginalValues, StringComparer.Ordinal) |
| | 30 | 39 | | .ThenBy(record => record.CurrentValues, StringComparer.Ordinal) |
| | 30 | 40 | | .ThenBy(record => record.OperationExecutionId, StringComparer.Ordinal) |
| | 30 | 41 | | .ThenBy(record => record.ExecutionAttemptId, StringComparer.Ordinal) |
| | 30 | 42 | | .ThenBy(record => record.DecisionAuditRecordId, StringComparer.Ordinal) |
| | 30 | 43 | | .ThenBy(record => record.CorrelationId, StringComparer.Ordinal) |
| | 30 | 44 | | .ThenBy(record => record.TraceId, StringComparer.Ordinal) |
| | 30 | 45 | | .ThenBy(record => record.SpanId, StringComparer.Ordinal) |
| | 30 | 46 | | .ThenBy(record => record.SchemaVersion, StringComparer.Ordinal) |
| | 84 | 47 | | .ToList(); |
| | | 48 | | |
| | 84 | 49 | | using var stream = new MemoryStream(); |
| | 84 | 50 | | using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false })) |
| | | 51 | | { |
| | 84 | 52 | | writer.WriteStartObject(); |
| | 84 | 53 | | writer.WriteString("schemaVersion", ApplicationMutationManifest.CurrentSchemaVersion); |
| | 84 | 54 | | writer.WriteString("mutationBatchId", mutationBatchId); |
| | 84 | 55 | | writer.WriteNumber("auditRecordCount", canonicalRecords.Count); |
| | 84 | 56 | | writer.WritePropertyName("records"); |
| | 84 | 57 | | writer.WriteStartArray(); |
| | | 58 | | |
| | 368 | 59 | | foreach (CanonicalAuditRecord record in canonicalRecords) |
| | | 60 | | { |
| | 100 | 61 | | WriteRecord(writer, record); |
| | | 62 | | } |
| | | 63 | | |
| | 84 | 64 | | writer.WriteEndArray(); |
| | 84 | 65 | | writer.WriteEndObject(); |
| | 84 | 66 | | } |
| | | 67 | | |
| | 84 | 68 | | return new ApplicationMutationManifest( |
| | 84 | 69 | | ApplicationMutationManifest.CurrentSchemaVersion, |
| | 84 | 70 | | mutationBatchId, |
| | 84 | 71 | | canonicalRecords.Count, |
| | 84 | 72 | | Encoding.UTF8.GetString(stream.ToArray())); |
| | 84 | 73 | | } |
| | | 74 | | |
| | | 75 | | private static CanonicalAuditRecord CreateCanonicalRecord(AuditRecord record) |
| | | 76 | | { |
| | 100 | 77 | | ArgumentNullException.ThrowIfNull(record); |
| | | 78 | | |
| | 100 | 79 | | return new CanonicalAuditRecord( |
| | 100 | 80 | | record.SchemaVersion ?? string.Empty, |
| | 100 | 81 | | record.Entity ?? string.Empty, |
| | 100 | 82 | | record.State ?? string.Empty, |
| | 100 | 83 | | CanonicalizeValueObject(record.KeyValues), |
| | 100 | 84 | | CanonicalizeValueObject(record.OriginalValues), |
| | 100 | 85 | | CanonicalizeValueObject(record.CurrentValues), |
| | 100 | 86 | | record.OperationExecutionId ?? string.Empty, |
| | 100 | 87 | | record.ExecutionAttemptId ?? string.Empty, |
| | 100 | 88 | | record.DecisionAuditRecordId ?? string.Empty, |
| | 100 | 89 | | record.CorrelationId ?? string.Empty, |
| | 100 | 90 | | record.TraceId ?? string.Empty, |
| | 100 | 91 | | record.SpanId ?? string.Empty); |
| | | 92 | | } |
| | | 93 | | |
| | | 94 | | private static string CanonicalizeValueObject(string? json) |
| | | 95 | | { |
| | 300 | 96 | | if (string.IsNullOrWhiteSpace(json)) |
| | | 97 | | { |
| | 66 | 98 | | return "{}"; |
| | | 99 | | } |
| | | 100 | | |
| | 234 | 101 | | using var document = JsonDocument.Parse(json); |
| | 234 | 102 | | if (document.RootElement.ValueKind != JsonValueKind.Object) |
| | | 103 | | { |
| | 0 | 104 | | throw new InvalidOperationException("Audit value payloads must be JSON objects."); |
| | | 105 | | } |
| | | 106 | | |
| | 234 | 107 | | using var stream = new MemoryStream(); |
| | 234 | 108 | | using (var writer = new Utf8JsonWriter(stream)) |
| | | 109 | | { |
| | 234 | 110 | | WriteCanonicalElement(writer, document.RootElement); |
| | 234 | 111 | | } |
| | | 112 | | |
| | 234 | 113 | | return Encoding.UTF8.GetString(stream.ToArray()); |
| | 234 | 114 | | } |
| | | 115 | | |
| | | 116 | | private static void WriteCanonicalElement(Utf8JsonWriter writer, JsonElement element) |
| | | 117 | | { |
| | 996 | 118 | | switch (element.ValueKind) |
| | | 119 | | { |
| | | 120 | | case JsonValueKind.Object: |
| | 234 | 121 | | writer.WriteStartObject(); |
| | 2754 | 122 | | foreach (JsonProperty property in element.EnumerateObject().OrderBy(property => property.Name, StringCom |
| | | 123 | | { |
| | 762 | 124 | | writer.WritePropertyName(property.Name); |
| | 762 | 125 | | WriteCanonicalElement(writer, property.Value); |
| | | 126 | | } |
| | 234 | 127 | | writer.WriteEndObject(); |
| | 234 | 128 | | break; |
| | | 129 | | case JsonValueKind.Array: |
| | 0 | 130 | | writer.WriteStartArray(); |
| | 0 | 131 | | foreach (JsonElement item in element.EnumerateArray()) |
| | | 132 | | { |
| | 0 | 133 | | WriteCanonicalElement(writer, item); |
| | | 134 | | } |
| | 0 | 135 | | writer.WriteEndArray(); |
| | 0 | 136 | | break; |
| | | 137 | | case JsonValueKind.String: |
| | | 138 | | case JsonValueKind.Number: |
| | | 139 | | case JsonValueKind.True: |
| | | 140 | | case JsonValueKind.False: |
| | | 141 | | case JsonValueKind.Null: |
| | 762 | 142 | | element.WriteTo(writer); |
| | 762 | 143 | | break; |
| | | 144 | | case JsonValueKind.Undefined: |
| | 0 | 145 | | throw new InvalidOperationException("Undefined JSON values cannot be represented in a canonical mutation |
| | | 146 | | default: |
| | 0 | 147 | | throw new InvalidOperationException($"Unsupported JSON value kind '{element.ValueKind}'."); |
| | | 148 | | } |
| | | 149 | | } |
| | | 150 | | |
| | | 151 | | private static void WriteRecord(Utf8JsonWriter writer, CanonicalAuditRecord record) |
| | | 152 | | { |
| | 100 | 153 | | writer.WriteStartObject(); |
| | 100 | 154 | | writer.WriteString("schemaVersion", record.SchemaVersion); |
| | 100 | 155 | | writer.WriteString("entity", record.Entity); |
| | 100 | 156 | | writer.WriteString("state", record.State); |
| | 100 | 157 | | WriteCanonicalObjectProperty(writer, "keyValues", record.KeyValues); |
| | 100 | 158 | | WriteCanonicalObjectProperty(writer, "originalValues", record.OriginalValues); |
| | 100 | 159 | | WriteCanonicalObjectProperty(writer, "currentValues", record.CurrentValues); |
| | 100 | 160 | | writer.WriteString("operationExecutionId", record.OperationExecutionId); |
| | 100 | 161 | | writer.WriteString("executionAttemptId", record.ExecutionAttemptId); |
| | 100 | 162 | | writer.WriteString("decisionAuditRecordId", record.DecisionAuditRecordId); |
| | 100 | 163 | | writer.WriteString("correlationId", record.CorrelationId); |
| | 100 | 164 | | writer.WriteString("traceId", record.TraceId); |
| | 100 | 165 | | writer.WriteString("spanId", record.SpanId); |
| | 100 | 166 | | writer.WriteEndObject(); |
| | 100 | 167 | | } |
| | | 168 | | |
| | | 169 | | private static void WriteCanonicalObjectProperty(Utf8JsonWriter writer, string propertyName, string canonicalJson) |
| | | 170 | | { |
| | 300 | 171 | | writer.WritePropertyName(propertyName); |
| | 300 | 172 | | using var document = JsonDocument.Parse(canonicalJson); |
| | 300 | 173 | | document.RootElement.WriteTo(writer); |
| | 600 | 174 | | } |
| | | 175 | | |
| | 100 | 176 | | private sealed record CanonicalAuditRecord( |
| | 130 | 177 | | string SchemaVersion, |
| | 130 | 178 | | string Entity, |
| | 130 | 179 | | string State, |
| | 130 | 180 | | string KeyValues, |
| | 130 | 181 | | string OriginalValues, |
| | 130 | 182 | | string CurrentValues, |
| | 130 | 183 | | string OperationExecutionId, |
| | 130 | 184 | | string ExecutionAttemptId, |
| | 130 | 185 | | string DecisionAuditRecordId, |
| | 130 | 186 | | string CorrelationId, |
| | 130 | 187 | | string TraceId, |
| | 230 | 188 | | string SpanId); |
| | | 189 | | } |