-
-
Notifications
You must be signed in to change notification settings - Fork 320
feat: add app import preview flow #239
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -14,6 +14,7 @@ | |||||||
| using AgileConfig.Server.Event; | ||||||||
| using AgileConfig.Server.IService; | ||||||||
| using Microsoft.AspNetCore.Authorization; | ||||||||
| using Microsoft.AspNetCore.Http; | ||||||||
| using Microsoft.AspNetCore.Mvc; | ||||||||
| using Newtonsoft.Json; | ||||||||
|
|
||||||||
|
|
@@ -382,6 +383,243 @@ public async Task<IActionResult> Export([FromBody] AppExportRequest model) | |||||||
| return File(Encoding.UTF8.GetBytes(json), "application/json", fileName); | ||||||||
| } | ||||||||
|
|
||||||||
| [TypeFilter(typeof(PermissionCheckAttribute), Arguments = new object[] { Functions.App_Add })] | ||||||||
| [HttpPost] | ||||||||
| public async Task<IActionResult> PreviewImport(IFormFile file) | ||||||||
| { | ||||||||
| var importFile = await ReadImportFileAsync(file); | ||||||||
| var preview = await BuildImportPreviewAsync(importFile); | ||||||||
| return Json(new | ||||||||
| { | ||||||||
| success = !preview.Errors.Any(), | ||||||||
| data = preview, | ||||||||
| message = preview.Errors.FirstOrDefault() | ||||||||
| }); | ||||||||
| } | ||||||||
|
|
||||||||
| [TypeFilter(typeof(PermissionCheckAttribute), Arguments = new object[] { Functions.App_Add })] | ||||||||
| [HttpPost] | ||||||||
| public async Task<IActionResult> Import([FromBody] AppImportRequest model) | ||||||||
| { | ||||||||
| ArgumentNullException.ThrowIfNull(model); | ||||||||
| ArgumentNullException.ThrowIfNull(model.File); | ||||||||
|
|
||||||||
| var preview = await BuildImportPreviewAsync(model.File); | ||||||||
| if (preview.Errors.Any()) | ||||||||
| return Json(new | ||||||||
| { | ||||||||
| success = false, | ||||||||
| data = preview, | ||||||||
| message = string.Join(Environment.NewLine, preview.Errors) | ||||||||
| }); | ||||||||
|
|
||||||||
| var currentUserId = await this.GetCurrentUserId(_userService); | ||||||||
| var now = DateTime.Now; | ||||||||
|
|
||||||||
| foreach (var previewItem in preview.Apps.OrderBy(x => x.Order)) | ||||||||
| { | ||||||||
| var importItem = model.File.Apps.First(x => string.Equals(x.App?.Id, previewItem.AppId, StringComparison.OrdinalIgnoreCase)); | ||||||||
| var app = new App | ||||||||
| { | ||||||||
| Id = importItem.App.Id, | ||||||||
| Name = importItem.App.Name, | ||||||||
| Group = importItem.App.Group, | ||||||||
| Secret = importItem.App.Secret, | ||||||||
| Enabled = importItem.App.Enabled, | ||||||||
| Type = importItem.App.Inheritanced ? AppType.Inheritance : AppType.PRIVATE, | ||||||||
| CreateTime = now, | ||||||||
| Creator = currentUserId | ||||||||
| }; | ||||||||
|
|
||||||||
| var inheritanceApps = BuildInheritanceLinks(importItem.App.InheritancedApps, app.Id); | ||||||||
| await _appService.AddAsync(app, inheritanceApps); | ||||||||
|
Comment on lines
+434
to
+435
|
||||||||
|
|
||||||||
| foreach (var envConfigs in importItem.Envs) | ||||||||
| { | ||||||||
| foreach (var configVm in envConfigs.Value ?? new List<AppExportConfigVM>()) | ||||||||
| { | ||||||||
| var config = new Config | ||||||||
| { | ||||||||
| Id = Guid.NewGuid().ToString("N"), | ||||||||
| AppId = app.Id, | ||||||||
| Env = envConfigs.Key, | ||||||||
| Group = configVm.Group, | ||||||||
| Key = configVm.Key, | ||||||||
| Value = configVm.Value, | ||||||||
| Description = configVm.Description, | ||||||||
| CreateTime = now, | ||||||||
| Status = ConfigStatus.Enabled, | ||||||||
| OnlineStatus = OnlineStatus.WaitPublish, | ||||||||
| EditStatus = EditStatus.Add | ||||||||
| }; | ||||||||
| await _configService.AddAsync(config, envConfigs.Key); | ||||||||
|
||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| return Json(new | ||||||||
| { | ||||||||
| success = true, | ||||||||
| data = preview | ||||||||
| }); | ||||||||
|
Comment on lines
+460
to
+464
|
||||||||
| } | ||||||||
|
|
||||||||
| private static List<AppInheritanced> BuildInheritanceLinks(List<string> parentIds, string appId) | ||||||||
| { | ||||||||
| var inheritanceApps = new List<AppInheritanced>(); | ||||||||
| if (parentIds == null) return inheritanceApps; | ||||||||
|
|
||||||||
| var sort = 0; | ||||||||
| foreach (var parentId in parentIds.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase)) | ||||||||
| inheritanceApps.Add(new AppInheritanced | ||||||||
| { | ||||||||
| Id = Guid.NewGuid().ToString("N"), | ||||||||
| AppId = appId, | ||||||||
| InheritancedAppId = parentId, | ||||||||
| Sort = sort++ | ||||||||
| }); | ||||||||
|
|
||||||||
| return inheritanceApps; | ||||||||
| } | ||||||||
|
|
||||||||
| private async Task<AppExportFileVM> ReadImportFileAsync(IFormFile file) | ||||||||
| { | ||||||||
| if (file == null || file.Length == 0) throw new ArgumentException("file"); | ||||||||
|
|
||||||||
| using var stream = file.OpenReadStream(); | ||||||||
| using var reader = new System.IO.StreamReader(stream, Encoding.UTF8); | ||||||||
| var content = await reader.ReadToEndAsync(); | ||||||||
| var importFile = JsonConvert.DeserializeObject<AppExportFileVM>(content); | ||||||||
| if (importFile == null) throw new ArgumentException("file"); | ||||||||
|
Comment on lines
+485
to
+493
|
||||||||
|
|
||||||||
| return importFile; | ||||||||
| } | ||||||||
|
|
||||||||
| private async Task<AppImportPreviewVM> BuildImportPreviewAsync(AppExportFileVM importFile) | ||||||||
| { | ||||||||
| var preview = new AppImportPreviewVM(); | ||||||||
| if (importFile?.Apps == null || !importFile.Apps.Any()) | ||||||||
| { | ||||||||
| preview.Errors.Add("Import file does not contain any apps."); | ||||||||
| return preview; | ||||||||
| } | ||||||||
|
|
||||||||
| var appItems = importFile.Apps | ||||||||
| .Where(x => x?.App != null) | ||||||||
| .ToList(); | ||||||||
| if (!appItems.Any()) | ||||||||
| { | ||||||||
| preview.Errors.Add("Import file does not contain any valid app entries."); | ||||||||
| return preview; | ||||||||
| } | ||||||||
|
|
||||||||
| var duplicateIds = appItems | ||||||||
| .GroupBy(x => x.App.Id ?? string.Empty, StringComparer.OrdinalIgnoreCase) | ||||||||
| .Where(x => !string.IsNullOrWhiteSpace(x.Key) && x.Count() > 1) | ||||||||
| .Select(x => x.Key) | ||||||||
| .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) | ||||||||
| .ToList(); | ||||||||
| preview.Errors.AddRange(duplicateIds.Select(x => $"Duplicate AppId in import file: {x}.")); | ||||||||
|
|
||||||||
| var duplicateNames = appItems | ||||||||
| .GroupBy(x => x.App.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase) | ||||||||
| .Where(x => !string.IsNullOrWhiteSpace(x.Key) && x.Count() > 1) | ||||||||
| .Select(x => x.Key) | ||||||||
| .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) | ||||||||
| .ToList(); | ||||||||
| preview.Errors.AddRange(duplicateNames.Select(x => $"Duplicate app name in import file: {x}.")); | ||||||||
|
|
||||||||
| foreach (var item in appItems) | ||||||||
| { | ||||||||
| if (string.IsNullOrWhiteSpace(item.App.Id)) preview.Errors.Add("Imported app is missing AppId."); | ||||||||
| if (string.IsNullOrWhiteSpace(item.App.Name)) preview.Errors.Add("Imported app is missing Name."); | ||||||||
| } | ||||||||
|
|
||||||||
| var importedAppIds = new HashSet<string>(appItems.Select(x => x.App.Id).Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase); | ||||||||
| var existingApps = await _appService.GetAllAppsAsync(); | ||||||||
|
|
||||||||
| foreach (var item in appItems.Where(x => x.App != null && !string.IsNullOrWhiteSpace(x.App.Id))) | ||||||||
| { | ||||||||
| if (existingApps.Any(x => string.Equals(x.Id, item.App.Id, StringComparison.OrdinalIgnoreCase))) | ||||||||
| preview.Errors.Add($"AppId already exists: {item.App.Id}."); | ||||||||
| if (!string.IsNullOrWhiteSpace(item.App.Name) && existingApps.Any(x => string.Equals(x.Name, item.App.Name, StringComparison.OrdinalIgnoreCase))) | ||||||||
| preview.Errors.Add($"App name already exists: {item.App.Name}."); | ||||||||
| } | ||||||||
|
|
||||||||
| foreach (var item in appItems) | ||||||||
| { | ||||||||
| foreach (var parentId in item.App.InheritancedApps?.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase) ?? new List<string>()) | ||||||||
| { | ||||||||
| if (importedAppIds.Contains(parentId)) continue; | ||||||||
| if (existingApps.Any(x => string.Equals(x.Id, parentId, StringComparison.OrdinalIgnoreCase))) continue; | ||||||||
| preview.Errors.Add($"App '{item.App.Id}' references missing parent '{parentId}'. Parent must already exist or be included in the import file."); | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
|
||||||||
| if (preview.Errors.Any()) return preview; |
Copilot
AI
Apr 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BuildImportPreviewAsync can add validation errors for missing/duplicate AppIds but still proceeds to TryTopologicalSort. ToDictionary(x => x.App.Id, ...) will throw when an AppId is null/whitespace or duplicated, turning a user error into a 500. Mandatory fix: short-circuit before topo sort when preview.Errors.Any(), and/or pass only items with non-empty unique AppIds into TryTopologicalSort so preview errors are returned as JSON instead of throwing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import derives
App.Typesolely fromInheritanced, ignoring the exportedtypefield included in the import schema (and likely inAppExportAppVM). This can change app types on import (e.g., any non-inheritance type becomesPRIVATE). Mandatory fix: map the exportedTypevalue toApp.Type(with validation/fallback if the value is unknown) instead of hardcoding it based onInheritanced.