UpsertInventoryUseCase.cs 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. using System.Collections.Specialized;
  2. using System.ComponentModel.DataAnnotations;
  3. using System.Text.Json;
  4. using System.Text.Json.Serialization;
  5. using RackPeek.Domain.Discovery;
  6. using RackPeek.Domain.Persistence;
  7. using RackPeek.Domain.Persistence.Yaml;
  8. using RackPeek.Domain.Resources;
  9. using RackPeek.Domain.Resources.Connections;
  10. using YamlDotNet.Serialization;
  11. using YamlDotNet.Serialization.NamingConventions;
  12. namespace RackPeek.Domain.Api;
  13. public class UpsertInventoryUseCase(
  14. IResourceCollection repo,
  15. IResourceYamlMigrationService migrationService)
  16. : IUseCase {
  17. private static readonly JsonSerializerOptions _jsonOptions = new() {
  18. PropertyNameCaseInsensitive = true,
  19. WriteIndented = false,
  20. DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
  21. ReferenceHandler = ReferenceHandler.IgnoreCycles,
  22. TypeInfoResolver = ResourcePolymorphismResolver.Create()
  23. };
  24. public async Task<ImportYamlResponse> ExecuteAsync(ImportYamlRequest request) {
  25. if (request == null)
  26. throw new ValidationException("Invalid request.");
  27. if (string.IsNullOrWhiteSpace(request.Yaml) && request.Json == null)
  28. throw new ValidationException("Either 'yaml' or 'json' must be provided.");
  29. if (!string.IsNullOrWhiteSpace(request.Yaml) && request.Json != null)
  30. throw new ValidationException("Provide either 'yaml' or 'json', not both.");
  31. YamlRoot incomingRoot;
  32. string yamlInput;
  33. if (!string.IsNullOrWhiteSpace(request.Yaml)) {
  34. yamlInput = request.Yaml!;
  35. incomingRoot = await migrationService.DeserializeAsync(yamlInput)
  36. ?? throw new ValidationException("Invalid YAML structure.");
  37. }
  38. else {
  39. if (request.Json is not JsonElement element)
  40. throw new ValidationException("Invalid JSON payload.");
  41. var rawJson = element.GetRawText();
  42. incomingRoot = JsonSerializer.Deserialize<YamlRoot>(
  43. rawJson,
  44. _jsonOptions)
  45. ?? throw new ValidationException("Invalid JSON structure.");
  46. yamlInput = YamlResourceCollection.SerializeRootAsync(incomingRoot);
  47. }
  48. if (incomingRoot.Resources == null)
  49. throw new ValidationException("Missing 'resources' section.");
  50. // 2️Compute Diff
  51. List<Resource>? incomingResources = incomingRoot.Resources;
  52. IReadOnlyList<Resource> currentResources = await repo.GetAllOfTypeAsync<Resource>();
  53. // Line discovered resources up with what they already map to before anything
  54. // else looks at names, so the diff below reports against the right resources.
  55. DiscoveryIdResolver.ResolveNames(currentResources, incomingResources, incomingRoot.Connections);
  56. IGrouping<string, Resource>? duplicate = incomingResources
  57. .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
  58. .FirstOrDefault(g => g.Count() > 1);
  59. if (duplicate != null)
  60. throw new ValidationException($"Duplicate resource name: {duplicate.Key}");
  61. var currentDict = currentResources
  62. .ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase);
  63. ISerializer serializerDiff = new SerializerBuilder()
  64. .WithNamingConvention(CamelCaseNamingConvention.Instance)
  65. .ConfigureDefaultValuesHandling(
  66. DefaultValuesHandling.OmitNull |
  67. DefaultValuesHandling.OmitEmptyCollections)
  68. .Build();
  69. var oldSnapshots = currentResources
  70. .ToDictionary(
  71. r => r.Name,
  72. r => serializerDiff.Serialize(r),
  73. StringComparer.OrdinalIgnoreCase);
  74. List<Resource> mergedResources = ResourceCollectionMerger.Merge(
  75. currentResources,
  76. incomingResources,
  77. request.Mode);
  78. var mergedDict = mergedResources
  79. .ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase);
  80. var response = new ImportYamlResponse();
  81. foreach (Resource incoming in incomingResources) {
  82. if (!mergedDict.TryGetValue(incoming.Name, out Resource? merged))
  83. continue;
  84. var newYaml = serializerDiff.Serialize(merged);
  85. response.NewYaml[incoming.Name] = newYaml;
  86. if (!currentDict.ContainsKey(incoming.Name)) {
  87. response.Added.Add(incoming.Name);
  88. continue;
  89. }
  90. var oldYaml = oldSnapshots[incoming.Name];
  91. response.OldYaml[incoming.Name] = oldYaml;
  92. Resource existing = currentDict[incoming.Name];
  93. if (request.Mode == MergeMode.Replace ||
  94. existing.GetType() != incoming.GetType())
  95. response.Replaced.Add(incoming.Name);
  96. else if (oldYaml != newYaml) response.Updated.Add(incoming.Name);
  97. }
  98. IReadOnlyList<Connection> currentConnections = await repo.GetConnectionsAsync();
  99. List<Connection>? mergedConnections = ConnectionMerger.Merge(
  100. currentConnections,
  101. incomingRoot.Connections,
  102. request.Mode);
  103. if (mergedConnections != null) {
  104. response.ConnectionsAdded = mergedConnections
  105. .Select(ConnectionMerger.Describe)
  106. .Except(currentConnections.Select(ConnectionMerger.Describe))
  107. .ToList();
  108. response.ConnectionsRemoved = currentConnections
  109. .Select(ConnectionMerger.Describe)
  110. .Except(mergedConnections.Select(ConnectionMerger.Describe))
  111. .ToList();
  112. }
  113. if (!request.DryRun) await repo.Merge(yamlInput, request.Mode);
  114. return response;
  115. }
  116. }