diff --git a/README.md b/README.md index 451e822..2aa6831 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ The main dashboard now exposes a test-selection panel before execution: - The results view also has live search for APIs, endpoints, tests, assertions, and error text. - Matching text is highlighted in both selection and result sections to make hits easier to spot. - Environment, endpoint, and individual test checkboxes let you run only the subset you care about. +- Each endpoint row in test selection also exposes an `Edit endpoint` action that opens the cURL import page preloaded with that endpoint's current request and tests. - Each page load starts with all tests selected by default. - The dashboard and cURL import pages use local AdminLTE assets, so the UI does not rely on external CDNs at runtime. @@ -112,7 +113,9 @@ The tool will: - parse the request URL, method, headers, query string, and JSON body - optionally parse a pasted JSON response body in the same flow so the assertion builder stays on the same page -- let you add assertion drafts first, then use the main `Analyze and Generate` action to include them in the endpoint YAML preview +- let you create multiple drafted tests, each with its own expected status and assertion set, then use `Analyze and Generate` to include all of them in the endpoint YAML preview +- open directly from the dashboard's `Edit endpoint` action so existing endpoint requests and tests can be adjusted in the importer flow +- save an edited endpoint back to its owning endpoint YAML file with `Save Endpoint YAML` - scan the configured YAML suite for an existing environment whose `baseUrl` already covers the pasted request URL - scan for an existing endpoint with the same method and either the same relative path or a matching path template such as `/customers/{customerId}` - generate suggested environment YAML when the base URL is not already present @@ -125,7 +128,9 @@ Current first-version scope: - best support is for common `curl`, `-X`, `-H`, `--data`, `--data-raw`, `--data-binary`, and `--url` forms - the page now uses one main `Analyze and Generate` action instead of separate request-analysis and response-parse steps +- each drafted test in the cURL page keeps its own assertions, so you can generate multiple YAML tests for one endpoint in a single pass - generated YAML is shown as preview text, not written directly to disk +- when editing an existing endpoint from the dashboard, the cURL importer can now write the updated endpoint back to its original YAML file - the assertion builder currently targets the most common field assertions: `equals`, `notEquals`, `type`, `containsText`, `startsWith`, `endsWith`, `notEmpty`, `greaterThan`, `greaterThanOrEqual`, `lessThan`, `lessThanOrEqual`, `minCount`, `maxCount`, and `count` - missing YAML warnings are surfaced in the cURL import UI, but malformed YAML content still needs to be fixed before the dashboard runner can execute the suite @@ -300,6 +305,12 @@ assertions: - field: data.accounts contains: status: Active + + - field: data.accounts + contains: + portfolioValue: + greaterThan: 1000 + status: Active ``` ## Dynamic parameters and variables diff --git a/src/ApiTestRunner.App/Models/CurlContracts.cs b/src/ApiTestRunner.App/Models/CurlContracts.cs index 1a3afbb..dec6dec 100644 --- a/src/ApiTestRunner.App/Models/CurlContracts.cs +++ b/src/ApiTestRunner.App/Models/CurlContracts.cs @@ -4,8 +4,14 @@ public sealed class CurlAnalyzeRequest { public string Command { get; init; } = string.Empty; + public string? EnvironmentId { get; init; } + + public string? EndpointName { get; init; } + public string? ResponseBody { get; init; } + public IReadOnlyList Tests { get; init; } = []; + public IReadOnlyList Assertions { get; init; } = []; } @@ -52,6 +58,15 @@ public sealed class CurlAssertionDraft public object? Value { get; init; } } +public sealed class CurlTestDraft +{ + public string Name { get; init; } = string.Empty; + + public int ExpectedStatus { get; init; } = 200; + + public IReadOnlyList Assertions { get; init; } = []; +} + public sealed class CurlEnvironmentAnalysis { public bool Exists { get; init; } @@ -62,7 +77,7 @@ public sealed class CurlEnvironmentAnalysis public IReadOnlyList MatchedEnvironmentNames { get; init; } = []; - public IReadOnlyList Candidates { get; init; } = []; + public IReadOnlyList MatchedYamlPreviews { get; init; } = []; public string? SuggestedFilePath { get; init; } @@ -71,6 +86,8 @@ public sealed class CurlEnvironmentAnalysis public string? SuggestedYaml { get; init; } public string? DiffYaml { get; init; } + + public IReadOnlyList Candidates { get; init; } = []; } public sealed class CurlEndpointAnalysis @@ -83,7 +100,9 @@ public sealed class CurlEndpointAnalysis public IReadOnlyList MatchedEnvironmentNames { get; init; } = []; - public IReadOnlyList Candidates { get; init; } = []; + public IReadOnlyList MatchedYamlPreviews { get; init; } = []; + + public string? GeneratedYaml { get; init; } public string? SuggestedFilePath { get; init; } @@ -92,6 +111,15 @@ public sealed class CurlEndpointAnalysis public string? SuggestedYaml { get; init; } public string? DiffYaml { get; init; } + + public IReadOnlyList Candidates { get; init; } = []; +} + +public sealed class CurlYamlPreview +{ + public string Title { get; init; } = string.Empty; + + public string Yaml { get; init; } = string.Empty; } public sealed class CurlVariableAnalysis diff --git a/src/ApiTestRunner.App/Models/DashboardContracts.cs b/src/ApiTestRunner.App/Models/DashboardContracts.cs index f16f46d..698a26b 100644 --- a/src/ApiTestRunner.App/Models/DashboardContracts.cs +++ b/src/ApiTestRunner.App/Models/DashboardContracts.cs @@ -44,6 +44,49 @@ public sealed class DashboardEndpointManifest public IReadOnlyList Tests { get; init; } = []; } +public sealed class DashboardEndpointEditorSeed +{ + public string EnvironmentId { get; init; } = string.Empty; + + public string EnvironmentName { get; init; } = string.Empty; + + public string EndpointId { get; init; } = string.Empty; + + public string EndpointName { get; init; } = string.Empty; + + public string? SourceFilePath { get; init; } + + public string CurlCommand { get; init; } = string.Empty; + + public IReadOnlyList Tests { get; init; } = []; +} + +public sealed class DashboardEndpointSaveRequest +{ + public string EnvironmentId { get; init; } = string.Empty; + + public string EndpointId { get; init; } = string.Empty; + + public string EndpointName { get; init; } = string.Empty; + + public string Command { get; init; } = string.Empty; + + public IReadOnlyList Tests { get; init; } = []; +} + +public sealed class DashboardEndpointSaveResponse +{ + public string EnvironmentId { get; init; } = string.Empty; + + public string EndpointId { get; init; } = string.Empty; + + public string EndpointName { get; init; } = string.Empty; + + public string FilePath { get; init; } = string.Empty; + + public DateTimeOffset SavedAtUtc { get; init; } +} + public sealed class DashboardTestManifest { public string Id { get; init; } = string.Empty; diff --git a/src/ApiTestRunner.App/Program.cs b/src/ApiTestRunner.App/Program.cs index 219b87e..51785a3 100644 --- a/src/ApiTestRunner.App/Program.cs +++ b/src/ApiTestRunner.App/Program.cs @@ -22,6 +22,7 @@ builder.Services.AddApiTestRunnerCore(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); if (cliExecutionOptions.Enabled) @@ -62,6 +63,39 @@ } }); +app.MapGet("/api/dashboard/editor-seed", async ( + string environmentId, + string endpointId, + TestRunCoordinator coordinator, + CancellationToken cancellationToken) => +{ + try + { + var seed = await coordinator.GetEditorSeedAsync(environmentId, endpointId, cancellationToken); + return Results.Ok(seed); + } + catch (Exception exception) + { + return Results.BadRequest(new { error = exception.Message }); + } +}); + +app.MapPost("/api/dashboard/editor-save", async ( + DashboardEndpointSaveRequest request, + TestRunCoordinator coordinator, + CancellationToken cancellationToken) => +{ + try + { + var response = await coordinator.SaveEditorAsync(request, cancellationToken); + return Results.Ok(response); + } + catch (Exception exception) + { + return Results.BadRequest(new { error = exception.Message }); + } +}); + app.MapPost("/api/dashboard/run", async (HttpRequest request, TestRunCoordinator coordinator, CancellationToken cancellationToken) => { var selection = request.ContentLength > 0 diff --git a/src/ApiTestRunner.App/Samples/Endpoints/pet/pet.yaml b/src/ApiTestRunner.App/Samples/Endpoints/pet/pet.yaml new file mode 100644 index 0000000..cb9824b --- /dev/null +++ b/src/ApiTestRunner.App/Samples/Endpoints/pet/pet.yaml @@ -0,0 +1,16 @@ +targetEnvironments: +- "PetstoreSwaggerIo" +endpoints: +- name: "GET Store Inventory" + method: "GET" + path: "/v2/store/inventory" + headers: + accept: "application/json" + api_key: "special-key" + tests: + - name: "Busy > 1 and Available < 1" + expectedStatus: 200 + assertions: + - field: "sold" + greaterThan: 51 +... diff --git a/src/ApiTestRunner.App/Samples/Environments/petstore.io.yaml b/src/ApiTestRunner.App/Samples/Environments/petstore.io.yaml new file mode 100644 index 0000000..4cf0265 --- /dev/null +++ b/src/ApiTestRunner.App/Samples/Environments/petstore.io.yaml @@ -0,0 +1,4 @@ +environments: +- name: "PetstoreSwaggerIo" + baseUrl: "https://petstore.swagger.io" +... \ No newline at end of file diff --git a/src/ApiTestRunner.App/Services/CurlCommandAnalyzer.cs b/src/ApiTestRunner.App/Services/CurlCommandAnalyzer.cs index 699a710..5c2b63e 100644 --- a/src/ApiTestRunner.App/Services/CurlCommandAnalyzer.cs +++ b/src/ApiTestRunner.App/Services/CurlCommandAnalyzer.cs @@ -35,7 +35,7 @@ public async Task AnalyzeAsync(CurlAnalyzeRequest request, throw new InvalidOperationException("Provide a cURL command to analyze."); } - var parsedRequest = ParseCurlCommand(request.Command); + var parsedRequest = CurlRequestParser.Parse(request.Command); var warnings = new List(); var loadedSuite = await TryLoadSuiteAsync(warnings, cancellationToken); if (warnings.Count == 0 && @@ -48,7 +48,7 @@ public async Task AnalyzeAsync(CurlAnalyzeRequest request, var variableSuggestions = BuildVariableSuggestions(parsedRequest); var matchedEnvironmentInfos = loadedSuite.Suite.Environments - .Select(environment => TryMatchEnvironment(environment, parsedRequest.Url)) + .Select(environment => CurlRequestParser.TryMatchEnvironment(environment, parsedRequest.Url)) .Where(match => match is not null) .Select(match => match!) .OrderByDescending(match => NormalizeBaseUrl(match.Environment.BaseUrl).Length) @@ -60,23 +60,35 @@ public async Task AnalyzeAsync(CurlAnalyzeRequest request, .DistinctBy(environment => environment.Name, StringComparer.OrdinalIgnoreCase) .ToArray(); + var matchedEnvironmentPreviews = matchedEnvironments + .Select(environment => new CurlYamlPreview + { + Title = environment.Name, + Yaml = SerializeYaml(new Dictionary + { + ["environments"] = new object?[] + { + BuildEnvironmentDocument(environment) + } + }) + }) + .ToArray(); var environmentCandidates = matchedEnvironmentInfos .GroupBy(match => match.Environment.Name, StringComparer.OrdinalIgnoreCase) .Select(group => { - var first = group.First(); + var match = group.First(); return new CurlEnvironmentCandidate { - Name = first.Environment.Name, - BaseUrl = first.Environment.BaseUrl, - RelativePath = first.RelativePath + Name = match.Environment.Name, + BaseUrl = match.Environment.BaseUrl, + RelativePath = match.RelativePath }; }) .OrderBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase) .ToArray(); - + var matchedEnvironment = matchedEnvironments.Length == 1 ? matchedEnvironments[0] : null; var environmentMatchStatus = GetMatchStatus(environmentCandidates.Length); - var matchedEnvironment = environmentCandidates.Length == 1 ? matchedEnvironments[0] : null; var effectivePath = matchedEnvironmentInfos.FirstOrDefault()?.RelativePath ?? parsedRequest.Path; var requestSummary = new CurlRequestSummary @@ -92,64 +104,109 @@ public async Task AnalyzeAsync(CurlAnalyzeRequest request, RawBody = parsedRequest.RawBody }; - var matchedEndpointInfos = matchedEnvironmentInfos + var matchedEndpointMatches = matchedEnvironmentInfos .SelectMany(match => match.Environment.Endpoints .Where(endpoint => MethodsMatch(endpoint.Method, parsedRequest.Method) && PathsMatch(endpoint.Path, match.RelativePath)) - .Select(endpoint => new MatchedEndpointInfo(match.Environment.Name, endpoint))) + .Select(endpoint => new EndpointMatch(match.Environment, endpoint))) .ToArray(); - var endpointCandidates = matchedEndpointInfos + var matchedEndpointEnvironments = matchedEndpointMatches + .Select(match => match.Environment.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var matchedEndpointPreviews = matchedEndpointMatches + .Select(match => new CurlYamlPreview + { + Title = $"{match.Environment.Name} - {match.Endpoint.Name}", + Yaml = SerializeYaml(new Dictionary + { + ["targetEnvironments"] = new[] { match.Environment.Name }, + ["endpoints"] = new object?[] + { + BuildEndpointDocument(match.Endpoint) + } + }) + }) + .ToArray(); + var endpointCandidates = matchedEndpointMatches .GroupBy( - match => $"{match.Endpoint.Name}|{match.Endpoint.Method}|{NormalizePath(match.Endpoint.Path)}", + match => $"{match.Environment.Name}|{match.Endpoint.Method}|{match.Endpoint.Path}|{match.Endpoint.Name}", StringComparer.OrdinalIgnoreCase) .Select(group => { - var first = group.First().Endpoint; + var match = group.First(); return new CurlEndpointCandidate { - Name = first.Name, - Method = first.Method.ToUpperInvariant(), - Path = NormalizePath(first.Path), - EnvironmentNames = group - .Select(match => match.EnvironmentName) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) - .ToArray() + Name = match.Endpoint.Name, + Method = match.Endpoint.Method, + Path = match.Endpoint.Path, + EnvironmentNames = [match.Environment.Name] }; }) .OrderBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase) - .ThenBy(candidate => candidate.Path, StringComparer.OrdinalIgnoreCase) .ToArray(); - - var matchedEndpointEnvironments = endpointCandidates - .SelectMany(candidate => candidate.EnvironmentNames) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - var endpointMatchStatus = GetMatchStatus(endpointCandidates.Length); - var matchedEndpoint = endpointCandidates.Length == 1 ? endpointCandidates[0] : null; - var matchedEndpointDefinition = matchedEndpoint is null - ? null - : matchedEndpointInfos - .First(info => - string.Equals(info.Endpoint.Name, matchedEndpoint.Name, StringComparison.OrdinalIgnoreCase) && - MethodsMatch(info.Endpoint.Method, matchedEndpoint.Method) && - string.Equals(NormalizePath(info.Endpoint.Path), matchedEndpoint.Path, StringComparison.OrdinalIgnoreCase)) - .Endpoint; var suggestedEnvironmentName = matchedEnvironments.Length > 0 ? matchedEnvironments[0].Name : SuggestEnvironmentName(parsedRequest.BaseUrl); - var targetEnvironmentNames = matchedEnvironmentInfos.Length > 0 - ? matchedEnvironmentInfos - .Where(match => string.Equals(match.RelativePath, effectivePath, StringComparison.OrdinalIgnoreCase)) - .Select(match => match.Environment.Name) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray() - : [suggestedEnvironmentName]; + var selectedEnvironmentName = string.IsNullOrWhiteSpace(request.EnvironmentId) + ? null + : loadedSuite.Suite.Environments + .FirstOrDefault(environment => + string.Equals( + DashboardSuiteManifestFactory.CreateEnvironmentId(environment), + request.EnvironmentId, + StringComparison.OrdinalIgnoreCase)) + ?.Name; + + var targetEnvironmentNames = !string.IsNullOrWhiteSpace(selectedEnvironmentName) + ? [selectedEnvironmentName] + : matchedEnvironmentInfos.Length > 0 + ? matchedEnvironmentInfos + .Where(match => string.Equals(match.RelativePath, effectivePath, StringComparison.OrdinalIgnoreCase)) + .Select(match => match.Environment.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + : [suggestedEnvironmentName]; + + var endpointName = string.IsNullOrWhiteSpace(request.EndpointName) + ? SuggestEndpointName(parsedRequest.Method, effectivePath) + : request.EndpointName.Trim(); + var testDrafts = NormalizeTestDrafts(request, parsedRequest.Method, effectivePath); + var generatedEndpointYaml = GenerateEndpointYaml( + variableSuggestions.TransformedRequest, + effectivePath, + endpointName, + targetEnvironmentNames, + testDrafts); + var currentEnvironmentYaml = matchedEnvironment is null + ? null + : GenerateEnvironmentYamlFromDefinition(matchedEnvironment); + var suggestedEnvironmentYaml = environmentCandidates.Length switch + { + 0 => GenerateEnvironmentYaml(suggestedEnvironmentName, parsedRequest.BaseUrl, variableSuggestions.Variables), + 1 when matchedEnvironment is not null && variableSuggestions.Variables.Count > 0 + => GenerateEnvironmentYaml( + matchedEnvironment.Name, + matchedEnvironment.BaseUrl, + MergeVariables(matchedEnvironment.Variables, variableSuggestions.Variables)), + _ => null + }; + var environmentDiffYaml = currentEnvironmentYaml is not null && + suggestedEnvironmentYaml is not null && + !string.Equals(currentEnvironmentYaml, suggestedEnvironmentYaml, StringComparison.Ordinal) + ? GenerateDiff(currentEnvironmentYaml, suggestedEnvironmentYaml) + : null; + var currentEndpointYaml = matchedEndpointPreviews.Length == 1 ? matchedEndpointPreviews[0].Yaml : null; + var endpointDiffYaml = currentEndpointYaml is not null && + !string.Equals(currentEndpointYaml, generatedEndpointYaml, StringComparison.Ordinal) + ? GenerateDiff(currentEndpointYaml, generatedEndpointYaml) + : null; return new CurlAnalyzeResponse { @@ -160,73 +217,32 @@ public async Task AnalyzeAsync(CurlAnalyzeRequest request, MatchStatus = environmentMatchStatus, SuggestedName = suggestedEnvironmentName, MatchedEnvironmentNames = matchedEnvironments.Select(environment => environment.Name).ToArray(), - Candidates = environmentCandidates, - SuggestedFilePath = environmentCandidates.Length == 0 - ? BuildEnvironmentFilePath(suggestedEnvironmentName) - : null, - CurrentYaml = matchedEnvironment is null + MatchedYamlPreviews = matchedEnvironmentPreviews, + SuggestedFilePath = matchedEnvironments.Length > 0 ? null - : GenerateEnvironmentYamlFromDefinition(matchedEnvironment), - SuggestedYaml = environmentCandidates.Length switch - { - 0 => GenerateEnvironmentYaml(suggestedEnvironmentName, parsedRequest.BaseUrl, variableSuggestions.Variables), - 1 => GenerateEnvironmentYaml( - environmentCandidates[0].Name, - matchedEnvironment!.BaseUrl, - MergeVariables(matchedEnvironment!.Variables, variableSuggestions.Variables)), - _ => null - }, - DiffYaml = environmentCandidates.Length == 1 - ? GenerateDiff( - GenerateEnvironmentYamlFromDefinition(matchedEnvironment!), - GenerateEnvironmentYaml( - environmentCandidates[0].Name, - matchedEnvironment!.BaseUrl, - MergeVariables(matchedEnvironment!.Variables, variableSuggestions.Variables))) - : null + : BuildEnvironmentFilePath(suggestedEnvironmentName), + CurrentYaml = environmentMatchStatus == "matched" ? currentEnvironmentYaml : null, + SuggestedYaml = suggestedEnvironmentYaml, + DiffYaml = environmentDiffYaml, + Candidates = environmentMatchStatus == "ambiguous" ? environmentCandidates : [] }, Endpoint = new CurlEndpointAnalysis { Exists = matchedEndpointEnvironments.Length > 0, MatchStatus = endpointMatchStatus, - SuggestedName = matchedEndpoint?.Name ?? SuggestEndpointName(parsedRequest.Method, effectivePath), + SuggestedName = endpointName, MatchedEnvironmentNames = matchedEndpointEnvironments, - Candidates = endpointCandidates, - SuggestedFilePath = endpointCandidates.Length == 0 - ? BuildEndpointFilePath(parsedRequest.Method, effectivePath) - : null, - CurrentYaml = matchedEndpoint is null + MatchedYamlPreviews = matchedEndpointPreviews, + GeneratedYaml = generatedEndpointYaml, + SuggestedFilePath = matchedEndpointEnvironments.Length > 0 ? null - : GenerateEndpointYamlFromDefinition( - matchedEndpointDefinition!, - matchedEndpoint.EnvironmentNames), - SuggestedYaml = endpointCandidates.Length switch - { - 0 => GenerateEndpointYaml( - variableSuggestions.TransformedRequest, - effectivePath, - targetEnvironmentNames, - request.Assertions), - 1 => GenerateEndpointYaml( - variableSuggestions.TransformedRequest, - matchedEndpoint!.Path, - matchedEndpoint.EnvironmentNames, - request.Assertions, - matchedEndpoint.Name), - _ => null - }, - DiffYaml = endpointCandidates.Length == 1 - ? GenerateDiff( - GenerateEndpointYamlFromDefinition( - matchedEndpointDefinition!, - matchedEndpoint!.EnvironmentNames), - GenerateEndpointYaml( - variableSuggestions.TransformedRequest, - matchedEndpoint!.Path, - matchedEndpoint.EnvironmentNames, - request.Assertions, - matchedEndpoint.Name)) - : null + : BuildEndpointFilePath(parsedRequest.Method, effectivePath), + CurrentYaml = endpointMatchStatus == "matched" ? currentEndpointYaml : null, + SuggestedYaml = matchedEndpointEnvironments.Length > 0 + ? null + : generatedEndpointYaml, + DiffYaml = endpointMatchStatus == "matched" ? endpointDiffYaml : null, + Candidates = endpointMatchStatus == "ambiguous" ? endpointCandidates : [] }, Variables = new CurlVariableAnalysis { @@ -367,7 +383,7 @@ private static CurlRequestSummary ParseCurlCommand(string command) Method = (method ?? (combinedBody is null ? HttpMethod.Get.Method : HttpMethod.Post.Method)).ToUpperInvariant(), Url = uri.ToString(), BaseUrl = $"{uri.Scheme}://{uri.Authority}", - Path = string.IsNullOrWhiteSpace(uri.AbsolutePath) ? "/" : uri.AbsolutePath, + Path = GetUnescapedAbsolutePath(uri), Query = ParseQuery(uri), Headers = headers, Body = TryParseJsonBody(combinedBody), @@ -612,8 +628,8 @@ private static void AddHeader(IDictionary headers, string header return null; } - var environmentPath = NormalizePath(environmentUri.AbsolutePath); - var requestPath = NormalizePath(requestUri.AbsolutePath); + var environmentPath = NormalizePath(GetUnescapedAbsolutePath(environmentUri)); + var requestPath = NormalizePath(GetUnescapedAbsolutePath(requestUri)); if (!PathStartsWith(requestPath, environmentPath)) { @@ -882,14 +898,12 @@ private string GenerateEnvironmentYaml(string environmentName, string baseUrl, I { ["environments"] = new object?[] { - new Dictionary + BuildEnvironmentDocument(new EnvironmentDefinition { - ["name"] = environmentName, - ["baseUrl"] = baseUrl, - ["variables"] = variables.Count == 0 - ? null - : new Dictionary(variables, StringComparer.OrdinalIgnoreCase) - } + Name = environmentName, + BaseUrl = baseUrl, + Variables = new Dictionary(variables, StringComparer.OrdinalIgnoreCase) + }) } }; @@ -915,32 +929,16 @@ private string GenerateVariablesYaml(IReadOnlyDictionary variab private string GenerateEndpointYaml( CurlRequestSummary request, string endpointPath, + string endpointName, IReadOnlyList targetEnvironmentNames, - IReadOnlyList assertions, - string? endpointName = null) - { - var endpointDocument = new Dictionary - { - ["targetEnvironments"] = targetEnvironmentNames, - ["endpoints"] = new[] - { - BuildEndpointDocument(request, endpointPath, assertions, endpointName) - } - }; - - return SerializeYaml(endpointDocument); - } - - private string GenerateEndpointYamlFromDefinition( - EndpointDefinition endpoint, - IReadOnlyList targetEnvironmentNames) + IReadOnlyList tests) { var endpointDocument = new Dictionary { ["targetEnvironments"] = targetEnvironmentNames, ["endpoints"] = new[] { - BuildEndpointDocument(endpoint) + BuildEndpointDocument(request, endpointPath, endpointName, tests) } }; @@ -950,8 +948,8 @@ private string GenerateEndpointYamlFromDefinition( private Dictionary BuildEndpointDocument( CurlRequestSummary request, string endpointPath, - IReadOnlyList assertions, - string? endpointName = null) + string endpointName, + IReadOnlyList tests) { var resolvedEndpointName = string.IsNullOrWhiteSpace(endpointName) ? SuggestEndpointName(request.Method, endpointPath) @@ -982,23 +980,23 @@ private string GenerateEndpointYamlFromDefinition( endpoint["body"] = request.Body; } - var testDefinition = new Dictionary - { - ["name"] = $"{resolvedEndpointName} should return success", - ["expectedStatus"] = 200 - }; + endpoint["tests"] = BuildTestDocuments(request.Method, endpointPath, tests); + return endpoint; + } - var assertionDocuments = BuildAssertionDocuments(assertions); - if (assertionDocuments.Count > 0) + private static Dictionary BuildEnvironmentDocument(EnvironmentDefinition environment) + { + return new Dictionary { - testDefinition["assertions"] = assertionDocuments; - } - - endpoint["tests"] = new[] { testDefinition }; - return endpoint; + ["name"] = environment.Name, + ["baseUrl"] = environment.BaseUrl, + ["variables"] = environment.Variables.Count == 0 + ? null + : new Dictionary(environment.Variables, StringComparer.OrdinalIgnoreCase) + }; } - private Dictionary BuildEndpointDocument(EndpointDefinition endpoint) + private static Dictionary BuildEndpointDocument(EndpointDefinition endpoint) { var document = new Dictionary { @@ -1009,17 +1007,20 @@ private string GenerateEndpointYamlFromDefinition( if (endpoint.PathParams.Count > 0) { - document["pathParams"] = endpoint.PathParams.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase); + document["pathParams"] = new Dictionary(endpoint.PathParams, StringComparer.OrdinalIgnoreCase); } - if (endpoint.Headers.Count > 0) + if (endpoint.Query.Count > 0) { - document["headers"] = endpoint.Headers.ToDictionary(pair => pair.Key, pair => (object?)pair.Value, StringComparer.OrdinalIgnoreCase); + document["query"] = new Dictionary(endpoint.Query, StringComparer.OrdinalIgnoreCase); } - if (endpoint.Query.Count > 0) + if (endpoint.Headers.Count > 0) { - document["query"] = endpoint.Query.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase); + document["headers"] = endpoint.Headers.ToDictionary( + pair => pair.Key, + pair => (object?)pair.Value, + StringComparer.OrdinalIgnoreCase); } if (endpoint.Body is not null) @@ -1029,130 +1030,173 @@ private string GenerateEndpointYamlFromDefinition( if (endpoint.Tests.Count > 0) { - document["tests"] = endpoint.Tests.Select(BuildTestDocument).ToArray(); + document["tests"] = endpoint.Tests.Select(BuildTestDocument).ToList(); } return document; } - private static Dictionary BuildTestDocument(TestDefinition test) + private static List> BuildTestDocuments( + string method, + string endpointPath, + IReadOnlyList tests) { - var document = new Dictionary - { - ["name"] = test.Name, - ["expectedStatus"] = test.ExpectedStatus - }; + var normalizedTests = tests + .Where(test => !string.IsNullOrWhiteSpace(test.Name)) + .ToArray(); - if (test.Assertions.Count > 0) + if (normalizedTests.Length == 0) { - document["assertions"] = test.Assertions.Select(BuildAssertionDocument).ToArray(); + normalizedTests = + [ + new CurlTestDraft + { + Name = $"{SuggestEndpointName(method, endpointPath)} should return success", + ExpectedStatus = 200, + Assertions = [] + } + ]; } - return document; - } + var documents = new List>(normalizedTests.Length); - private static Dictionary BuildAssertionDocument(AssertionDefinition assertion) - { - var document = new Dictionary + foreach (var test in normalizedTests) { - ["field"] = assertion.Field - }; + var testDefinition = new Dictionary + { + ["name"] = test.Name, + ["expectedStatus"] = test.ExpectedStatus + }; - if (assertion.EqualsValue is not null) - { - document["equals"] = assertion.EqualsValue; - } + var assertionDocuments = BuildAssertionDocuments(test.Assertions); + if (assertionDocuments.Count > 0) + { + testDefinition["assertions"] = assertionDocuments; + } - if (assertion.NotEquals is not null) - { - document["notEquals"] = assertion.NotEquals; + documents.Add(testDefinition); } - if (!string.IsNullOrWhiteSpace(assertion.Type)) - { - document["type"] = assertion.Type; - } + return documents; + } - if (!string.IsNullOrWhiteSpace(assertion.ContainsText)) - { - document["containsText"] = assertion.ContainsText; - } + private static List> BuildAssertionDocuments(IReadOnlyList assertions) + { + var documents = new List>(); - if (!string.IsNullOrWhiteSpace(assertion.StartsWith)) + foreach (var assertion in assertions) { - document["startsWith"] = assertion.StartsWith; - } + if (string.IsNullOrWhiteSpace(assertion.Field) || string.IsNullOrWhiteSpace(assertion.Rule)) + { + continue; + } - if (!string.IsNullOrWhiteSpace(assertion.EndsWith)) - { - document["endsWith"] = assertion.EndsWith; - } + var document = new Dictionary + { + ["field"] = assertion.Field + }; - if (assertion.NotEmpty is not null) - { - document["notEmpty"] = assertion.NotEmpty; + var rule = assertion.Rule.Trim(); + var normalizedRule = char.ToLowerInvariant(rule[0]) + rule[1..]; + document[normalizedRule] = ConvertAssertionValue(assertion.Value); + documents.Add(document); } - if (assertion.MinCount is not null) + return documents; + } + + private static Dictionary BuildTestDocument(TestDefinition test) + { + var document = new Dictionary { - document["minCount"] = assertion.MinCount; - } + ["name"] = test.Name, + ["expectedStatus"] = test.ExpectedStatus + }; - if (assertion.MaxCount is not null) + if (test.Assertions.Count > 0) { - document["maxCount"] = assertion.MaxCount; + document["assertions"] = test.Assertions + .Select(BuildAssertionDocument) + .ToList(); } - if (assertion.Count is not null) + return document; + } + + private static Dictionary BuildAssertionDocument(AssertionDefinition assertion) + { + var document = new Dictionary { - document["count"] = assertion.Count; - } + ["field"] = assertion.Field + }; + + AddAssertionValue(document, "equals", assertion.EqualsValue); + AddAssertionValue(document, "notEquals", assertion.NotEquals); + AddAssertionValue(document, "type", assertion.Type); + AddAssertionValue(document, "containsText", assertion.ContainsText); + AddAssertionValue(document, "startsWith", assertion.StartsWith); + AddAssertionValue(document, "endsWith", assertion.EndsWith); + AddAssertionValue(document, "notEmpty", assertion.NotEmpty); + AddAssertionValue(document, "minCount", assertion.MinCount); + AddAssertionValue(document, "maxCount", assertion.MaxCount); + AddAssertionValue(document, "count", assertion.Count); + AddAssertionValue(document, "greaterThan", assertion.GreaterThan); + AddAssertionValue(document, "greaterThanOrEqual", assertion.GreaterThanOrEqual); + AddAssertionValue(document, "lessThan", assertion.LessThan); + AddAssertionValue(document, "lessThanOrEqual", assertion.LessThanOrEqual); if (assertion.Contains.Count > 0) { - document["contains"] = assertion.Contains.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase); + document["contains"] = new Dictionary(assertion.Contains, StringComparer.OrdinalIgnoreCase); } return document; } - private static Dictionary MergeVariables( - IReadOnlyDictionary existingVariables, - IReadOnlyDictionary suggestedVariables) + private static void AddAssertionValue( + IDictionary document, + string key, + object? value) { - var merged = new Dictionary(existingVariables, StringComparer.OrdinalIgnoreCase); - - foreach (var pair in suggestedVariables) + if (value is null) { - merged[pair.Key] = pair.Value; + return; } - return merged; + document[key] = value; } - private static List> BuildAssertionDocuments(IReadOnlyList assertions) + private static IReadOnlyList NormalizeTestDrafts( + CurlAnalyzeRequest request, + string method, + string endpointPath) { - var documents = new List>(); - - foreach (var assertion in assertions) + if (request.Tests.Count > 0) { - if (string.IsNullOrWhiteSpace(assertion.Field) || string.IsNullOrWhiteSpace(assertion.Rule)) - { - continue; - } - - var document = new Dictionary - { - ["field"] = assertion.Field - }; - - var rule = assertion.Rule.Trim(); - var normalizedRule = char.ToLowerInvariant(rule[0]) + rule[1..]; - document[normalizedRule] = ConvertAssertionValue(assertion.Value); - documents.Add(document); + return request.Tests + .Where(test => !string.IsNullOrWhiteSpace(test.Name)) + .Select(test => new CurlTestDraft + { + Name = test.Name, + ExpectedStatus = test.ExpectedStatus <= 0 ? 200 : test.ExpectedStatus, + Assertions = test.Assertions + .Where(assertion => !string.IsNullOrWhiteSpace(assertion.Field) && !string.IsNullOrWhiteSpace(assertion.Rule)) + .ToArray() + }) + .ToArray(); } - return documents; + return + [ + new CurlTestDraft + { + Name = $"{SuggestEndpointName(method, endpointPath)} should return success", + ExpectedStatus = 200, + Assertions = request.Assertions + .Where(assertion => !string.IsNullOrWhiteSpace(assertion.Field) && !string.IsNullOrWhiteSpace(assertion.Rule)) + .ToArray() + } + ]; } private static object? ConvertAssertionValue(object? value) @@ -1242,6 +1286,27 @@ private static string ToSlug(string value) return string.IsNullOrWhiteSpace(normalized) ? "generated" : normalized; } + private static IReadOnlyDictionary MergeVariables( + IReadOnlyDictionary existingVariables, + IReadOnlyDictionary suggestedVariables) + { + var merged = new Dictionary(existingVariables, StringComparer.OrdinalIgnoreCase); + + foreach (var pair in suggestedVariables) + { + merged[pair.Key] = pair.Value; + } + + return merged; + } + + private static string GetUnescapedAbsolutePath(Uri uri) + { + return string.IsNullOrWhiteSpace(uri.AbsolutePath) + ? "/" + : Uri.UnescapeDataString(uri.AbsolutePath); + } + private static string RegisterVariable( IDictionary variables, string baseName, @@ -1397,7 +1462,7 @@ private static YamlSequenceNode BuildSequenceNode(IEnumerable values) private sealed record EnvironmentMatch(EnvironmentDefinition Environment, string RelativePath); - private sealed record MatchedEndpointInfo(string EnvironmentName, EndpointDefinition Endpoint); + private sealed record EndpointMatch(EnvironmentDefinition Environment, EndpointDefinition Endpoint); private sealed record VariableSuggestionResult( IReadOnlyDictionary Variables, diff --git a/src/ApiTestRunner.App/Services/CurlRequestParser.cs b/src/ApiTestRunner.App/Services/CurlRequestParser.cs new file mode 100644 index 0000000..54326f5 --- /dev/null +++ b/src/ApiTestRunner.App/Services/CurlRequestParser.cs @@ -0,0 +1,402 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using ApiTestRunner.App.Models; +using ApiTestRunner.Core.Models; + +namespace ApiTestRunner.App.Services; + +internal static class CurlRequestParser +{ + private static readonly Regex LineContinuationRegex = new(@"([\\`^])\s*\r?\n\s*", RegexOptions.Compiled); + + public static CurlRequestSummary Parse(string command) + { + var normalizedCommand = LineContinuationRegex.Replace(command.Trim(), " "); + var tokens = Tokenize(normalizedCommand); + + if (tokens.Count == 0) + { + throw new InvalidOperationException("The provided cURL command was empty."); + } + + var currentIndex = 0; + if (tokens[0].Equals("curl", StringComparison.OrdinalIgnoreCase) || + tokens[0].Equals("curl.exe", StringComparison.OrdinalIgnoreCase)) + { + currentIndex++; + } + + string? method = null; + string? url = null; + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + var bodySegments = new List(); + + while (currentIndex < tokens.Count) + { + var token = tokens[currentIndex]; + + switch (token) + { + case "-X": + case "--request": + method = ReadNextValue(tokens, ref currentIndex, token); + break; + case "-H": + case "--header": + var headerValue = ReadNextValue(tokens, ref currentIndex, token); + AddHeader(headers, headerValue); + break; + case "-d": + case "--data": + case "--data-raw": + case "--data-binary": + bodySegments.Add(ReadNextValue(tokens, ref currentIndex, token)); + method ??= HttpMethod.Post.Method; + break; + case "--url": + url = ReadNextValue(tokens, ref currentIndex, token); + break; + default: + if (token.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + token.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + url = token; + } + + break; + } + + currentIndex++; + } + + if (string.IsNullOrWhiteSpace(url)) + { + throw new InvalidOperationException("No URL was found in the cURL command."); + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + throw new InvalidOperationException($"The cURL command contained an invalid URL: {url}"); + } + + var combinedBody = bodySegments.Count == 0 ? null : string.Join(Environment.NewLine, bodySegments); + + return new CurlRequestSummary + { + Method = (method ?? (combinedBody is null ? HttpMethod.Get.Method : HttpMethod.Post.Method)).ToUpperInvariant(), + Url = uri.ToString(), + BaseUrl = $"{uri.Scheme}://{uri.Authority}", + Path = GetUnescapedAbsolutePath(uri), + Query = ParseQuery(uri), + Headers = headers, + Body = TryParseJsonBody(combinedBody), + RawBody = combinedBody + }; + } + + public static string ResolveRelativePath(EnvironmentDefinition environment, CurlRequestSummary request) + { + return TryMatchEnvironment(environment, request.Url)?.RelativePath ?? request.Path; + } + + private static IReadOnlyDictionary ParseQuery(Uri uri) + { + if (string.IsNullOrWhiteSpace(uri.Query)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var query = new Dictionary(StringComparer.OrdinalIgnoreCase); + var segments = uri.Query.TrimStart('?') + .Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var segment in segments) + { + var parts = segment.Split('=', 2); + var key = Uri.UnescapeDataString(parts[0]); + var value = parts.Length == 2 ? Uri.UnescapeDataString(parts[1]) : string.Empty; + query[key] = value; + } + + return query; + } + + private static object? TryParseJsonBody(string? body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return null; + } + + try + { + var node = JsonNode.Parse(body); + return ConvertJsonNode(node); + } + catch (JsonException) + { + return body; + } + } + + private static object? ConvertJsonNode(JsonNode? node) + { + return node switch + { + null => null, + JsonObject jsonObject => jsonObject.ToDictionary( + pair => pair.Key, + pair => ConvertJsonNode(pair.Value), + StringComparer.OrdinalIgnoreCase), + JsonArray jsonArray => jsonArray.Select(ConvertJsonNode).ToList(), + JsonValue jsonValue => ConvertJsonScalar(jsonValue), + _ => node.ToJsonString() + }; + } + + private static object? ConvertJsonScalar(JsonValue value) + { + if (value.TryGetValue(out var stringValue)) + { + return stringValue; + } + + if (value.TryGetValue(out var booleanValue)) + { + return booleanValue; + } + + if (value.TryGetValue(out var intValue)) + { + return intValue; + } + + if (value.TryGetValue(out var longValue)) + { + return longValue; + } + + if (value.TryGetValue(out var decimalValue)) + { + return decimalValue; + } + + if (value.TryGetValue(out var doubleValue)) + { + return doubleValue; + } + + return value.ToJsonString(); + } + + private static List Tokenize(string command) + { + var tokens = new List(); + var current = new StringBuilder(); + var inSingleQuotes = false; + var inDoubleQuotes = false; + + for (var index = 0; index < command.Length; index++) + { + var character = command[index]; + + if (inSingleQuotes) + { + if (character == '\'') + { + inSingleQuotes = false; + } + else + { + current.Append(character); + } + + continue; + } + + if (inDoubleQuotes) + { + if (character == '"' && !IsEscaped(command, index)) + { + inDoubleQuotes = false; + } + else if (character == '\\' && index + 1 < command.Length) + { + current.Append(command[++index]); + } + else + { + current.Append(character); + } + + continue; + } + + if (char.IsWhiteSpace(character)) + { + FlushToken(tokens, current); + continue; + } + + if (character == '\'') + { + inSingleQuotes = true; + continue; + } + + if (character == '"') + { + inDoubleQuotes = true; + continue; + } + + if (character == '\\' && index + 1 < command.Length) + { + current.Append(command[++index]); + continue; + } + + current.Append(character); + } + + if (inSingleQuotes || inDoubleQuotes) + { + throw new InvalidOperationException("The cURL command contains an unmatched quote."); + } + + FlushToken(tokens, current); + return tokens; + } + + private static void FlushToken(ICollection tokens, StringBuilder current) + { + if (current.Length == 0) + { + return; + } + + tokens.Add(current.ToString()); + current.Clear(); + } + + private static bool IsEscaped(string input, int index) + { + var slashCount = 0; + + for (var i = index - 1; i >= 0 && input[i] == '\\'; i--) + { + slashCount++; + } + + return slashCount % 2 == 1; + } + + private static string ReadNextValue(IReadOnlyList tokens, ref int currentIndex, string option) + { + if (currentIndex + 1 >= tokens.Count) + { + throw new InvalidOperationException($"The cURL option '{option}' is missing its value."); + } + + currentIndex++; + return tokens[currentIndex]; + } + + private static void AddHeader(IDictionary headers, string headerValue) + { + var separatorIndex = headerValue.IndexOf(':'); + if (separatorIndex <= 0) + { + return; + } + + var headerName = headerValue[..separatorIndex].Trim(); + var headerContent = headerValue[(separatorIndex + 1)..].Trim(); + + if (string.IsNullOrWhiteSpace(headerName)) + { + return; + } + + headers[headerName] = headerContent; + } + + public static EnvironmentMatch? TryMatchEnvironment(EnvironmentDefinition environment, string requestUrl) + { + if (!Uri.TryCreate(environment.BaseUrl, UriKind.Absolute, out var environmentUri) || + !Uri.TryCreate(requestUrl, UriKind.Absolute, out var requestUri)) + { + return null; + } + + if (!string.Equals(environmentUri.Scheme, requestUri.Scheme, StringComparison.OrdinalIgnoreCase) || + !string.Equals(environmentUri.Host, requestUri.Host, StringComparison.OrdinalIgnoreCase) || + environmentUri.Port != requestUri.Port) + { + return null; + } + + var environmentPath = NormalizePath(GetUnescapedAbsolutePath(environmentUri)); + var requestPath = NormalizePath(GetUnescapedAbsolutePath(requestUri)); + + if (!PathStartsWith(requestPath, environmentPath)) + { + return null; + } + + var relativePath = requestPath[environmentPath.Length..]; + if (string.IsNullOrWhiteSpace(relativePath)) + { + relativePath = "/"; + } + else if (!relativePath.StartsWith('/')) + { + relativePath = "/" + relativePath; + } + + return new EnvironmentMatch(environment, NormalizePath(relativePath)); + } + + public static string NormalizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return "/"; + } + + var normalized = path.Trim(); + if (!normalized.StartsWith('/')) + { + normalized = "/" + normalized; + } + + return normalized.Length > 1 ? normalized.TrimEnd('/') : normalized; + } + + private static string GetUnescapedAbsolutePath(Uri uri) + { + return string.IsNullOrWhiteSpace(uri.AbsolutePath) + ? "/" + : Uri.UnescapeDataString(uri.AbsolutePath); + } + + private static bool PathStartsWith(string requestPath, string environmentPath) + { + if (string.Equals(environmentPath, "/", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!requestPath.StartsWith(environmentPath, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return requestPath.Length == environmentPath.Length || + requestPath[environmentPath.Length] == '/'; + } + + public sealed record EnvironmentMatch(EnvironmentDefinition Environment, string RelativePath); +} diff --git a/src/ApiTestRunner.App/Services/DashboardEndpointEditorService.cs b/src/ApiTestRunner.App/Services/DashboardEndpointEditorService.cs new file mode 100644 index 0000000..d74da59 --- /dev/null +++ b/src/ApiTestRunner.App/Services/DashboardEndpointEditorService.cs @@ -0,0 +1,337 @@ +using System.Text.Json; +using ApiTestRunner.App.Models; +using ApiTestRunner.Core.Models; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace ApiTestRunner.App.Services; + +public sealed class DashboardEndpointEditorService +{ + private readonly IConfiguredTestSuiteProvider _suiteProvider; + private readonly IDeserializer _deserializer; + + public DashboardEndpointEditorService(IConfiguredTestSuiteProvider suiteProvider) + { + _suiteProvider = suiteProvider; + _deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithAttemptingUnquotedStringTypeDeserialization() + .IgnoreUnmatchedProperties() + .Build(); + } + + public async Task GetEditorSeedAsync( + string environmentId, + string endpointId, + CancellationToken cancellationToken = default) + { + var loadedSuite = await _suiteProvider.LoadAsync(cancellationToken); + var sourceEntry = await ResolveSourceEntryAsync(loadedSuite, environmentId, endpointId, cancellationToken); + var seed = DashboardSuiteManifestFactory.CreateEditorSeed( + sourceEntry.Environment, + sourceEntry.Endpoint, + environmentId, + endpointId); + + return new DashboardEndpointEditorSeed + { + EnvironmentId = seed.EnvironmentId, + EnvironmentName = seed.EnvironmentName, + EndpointId = seed.EndpointId, + EndpointName = sourceEntry.Endpoint.Name, + SourceFilePath = sourceEntry.FilePath, + CurlCommand = seed.CurlCommand, + Tests = seed.Tests + }; + } + + public async Task SaveAsync( + DashboardEndpointSaveRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + if (string.IsNullOrWhiteSpace(request.EnvironmentId) || string.IsNullOrWhiteSpace(request.EndpointId)) + { + throw new InvalidOperationException("The endpoint editor is missing the selected endpoint identity."); + } + + if (string.IsNullOrWhiteSpace(request.Command)) + { + throw new InvalidOperationException("Paste a cURL command before saving the endpoint."); + } + + var loadedSuite = await _suiteProvider.LoadAsync(cancellationToken); + var sourceEntry = await ResolveSourceEntryAsync(loadedSuite, request.EnvironmentId, request.EndpointId, cancellationToken); + var parsedRequest = CurlRequestParser.Parse(request.Command); + var environment = loadedSuite.Suite.Environments + .FirstOrDefault(candidate => + string.Equals(DashboardSuiteManifestFactory.CreateEnvironmentId(candidate), request.EnvironmentId, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException("The selected environment could not be found in the loaded YAML suite."); + var environmentMatch = CurlRequestParser.TryMatchEnvironment(environment, parsedRequest.Url) + ?? throw new InvalidOperationException( + $"The edited cURL command no longer matches environment '{environment.Name}' ({environment.BaseUrl})."); + + var endpointName = string.IsNullOrWhiteSpace(request.EndpointName) + ? sourceEntry.Endpoint.Name + : request.EndpointName.Trim(); + var endpointPath = environmentMatch.RelativePath; + + var updatedEndpoint = new EndpointDefinition + { + Name = endpointName, + Method = parsedRequest.Method, + Path = endpointPath, + PathParams = FilterPathParams(sourceEntry.Endpoint.PathParams, endpointPath), + Query = parsedRequest.Query.ToDictionary( + pair => pair.Key, + pair => (object?)pair.Value, + StringComparer.OrdinalIgnoreCase), + Headers = parsedRequest.Headers + .Where(pair => !string.Equals(pair.Key, "Host", StringComparison.OrdinalIgnoreCase) && + !string.Equals(pair.Key, "Content-Length", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase), + Body = parsedRequest.Body, + Tests = BuildTests(request.Tests, endpointName) + }; + + var updatedDocument = ReplaceEndpointInDocument(sourceEntry, updatedEndpoint); + var yaml = YamlDefinitionFormatter.SerializeApiTestDocument(updatedDocument) + Environment.NewLine; + await File.WriteAllTextAsync(sourceEntry.FilePath, yaml, cancellationToken); + + return new DashboardEndpointSaveResponse + { + EnvironmentId = request.EnvironmentId, + EndpointId = DashboardSuiteManifestFactory.CreateEndpointId(environment, updatedEndpoint), + EndpointName = updatedEndpoint.Name, + FilePath = sourceEntry.FilePath, + SavedAtUtc = DateTimeOffset.UtcNow + }; + } + + private static Dictionary FilterPathParams( + IReadOnlyDictionary existingPathParams, + string endpointPath) + { + var placeholders = endpointPath + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Where(segment => segment.StartsWith('{') && segment.EndsWith('}') && segment.Length > 2) + .Select(segment => segment[1..^1]) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return existingPathParams + .Where(pair => placeholders.Contains(pair.Key)) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase); + } + + private static List BuildTests( + IReadOnlyList drafts, + string endpointName) + { + var normalizedDrafts = drafts + .Where(test => !string.IsNullOrWhiteSpace(test.Name)) + .ToArray(); + + if (normalizedDrafts.Length == 0) + { + normalizedDrafts = + [ + new CurlTestDraft + { + Name = $"{endpointName} should return success", + ExpectedStatus = 200, + Assertions = [] + } + ]; + } + + return normalizedDrafts + .Select(draft => new TestDefinition + { + Name = draft.Name.Trim(), + ExpectedStatus = draft.ExpectedStatus <= 0 ? 200 : draft.ExpectedStatus, + Assertions = draft.Assertions + .Where(assertion => !string.IsNullOrWhiteSpace(assertion.Field) && !string.IsNullOrWhiteSpace(assertion.Rule)) + .Select(BuildAssertion) + .ToList() + }) + .ToList(); + } + + private static AssertionDefinition BuildAssertion(CurlAssertionDraft draft) + { + var normalizedValue = ConvertAssertionValue(draft.Value); + + return draft.Rule switch + { + "equals" => new AssertionDefinition { Field = draft.Field, EqualsValue = normalizedValue }, + "notEquals" => new AssertionDefinition { Field = draft.Field, NotEquals = normalizedValue }, + "type" => new AssertionDefinition { Field = draft.Field, Type = normalizedValue?.ToString() }, + "containsText" => new AssertionDefinition { Field = draft.Field, ContainsText = normalizedValue?.ToString() }, + "startsWith" => new AssertionDefinition { Field = draft.Field, StartsWith = normalizedValue?.ToString() }, + "endsWith" => new AssertionDefinition { Field = draft.Field, EndsWith = normalizedValue?.ToString() }, + "notEmpty" => new AssertionDefinition { Field = draft.Field, NotEmpty = normalizedValue }, + "greaterThan" => new AssertionDefinition { Field = draft.Field, GreaterThan = normalizedValue }, + "greaterThanOrEqual" => new AssertionDefinition { Field = draft.Field, GreaterThanOrEqual = normalizedValue }, + "lessThan" => new AssertionDefinition { Field = draft.Field, LessThan = normalizedValue }, + "lessThanOrEqual" => new AssertionDefinition { Field = draft.Field, LessThanOrEqual = normalizedValue }, + "minCount" => new AssertionDefinition { Field = draft.Field, MinCount = normalizedValue }, + "maxCount" => new AssertionDefinition { Field = draft.Field, MaxCount = normalizedValue }, + "count" => new AssertionDefinition { Field = draft.Field, Count = normalizedValue }, + "contains" => new AssertionDefinition + { + Field = draft.Field, + Contains = normalizedValue as Dictionary ?? new Dictionary(StringComparer.OrdinalIgnoreCase) + }, + _ => throw new InvalidOperationException($"Unsupported assertion rule '{draft.Rule}'.") + }; + } + + private static object? ConvertAssertionValue(object? value) + { + return value switch + { + null => null, + JsonElement element => ConvertJsonElement(element), + Dictionary dictionary => new Dictionary(dictionary, StringComparer.OrdinalIgnoreCase), + _ => value + }; + } + + private static object? ConvertJsonElement(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number when element.TryGetInt32(out var intValue) => intValue, + JsonValueKind.Number when element.TryGetInt64(out var longValue) => longValue, + JsonValueKind.Number when element.TryGetDecimal(out var decimalValue) => decimalValue, + JsonValueKind.Object => JsonSerializer.Deserialize>(element.GetRawText())? + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase), + JsonValueKind.Array => JsonSerializer.Deserialize>(element.GetRawText()), + JsonValueKind.Null => null, + _ => element.GetRawText() + }; + } + + private static ApiTestDocumentDefinition ReplaceEndpointInDocument(SourceEntry sourceEntry, EndpointDefinition endpoint) + { + if (sourceEntry.EnvironmentIndex is int environmentIndex) + { + var environments = sourceEntry.Document.Environments.ToList(); + var environment = environments[environmentIndex]; + var endpoints = environment.Endpoints.ToList(); + endpoints[sourceEntry.EndpointIndex] = endpoint; + environments[environmentIndex] = environment with { Endpoints = endpoints }; + + return sourceEntry.Document with + { + Environments = environments + }; + } + + var topLevelEndpoints = sourceEntry.Document.Endpoints.ToList(); + topLevelEndpoints[sourceEntry.EndpointIndex] = endpoint; + + return sourceEntry.Document with + { + Endpoints = topLevelEndpoints + }; + } + + private async Task ResolveSourceEntryAsync( + LoadedTestSuite loadedSuite, + string environmentId, + string endpointId, + CancellationToken cancellationToken) + { + foreach (var filePath in loadedSuite.FilePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + + var yaml = await File.ReadAllTextAsync(filePath, cancellationToken); + var document = _deserializer.Deserialize(yaml) ?? new ApiTestDocumentDefinition(); + + for (var environmentIndex = 0; environmentIndex < document.Environments.Count; environmentIndex++) + { + var environment = document.Environments[environmentIndex]; + var aggregateEnvironment = loadedSuite.Suite.Environments + .FirstOrDefault(candidate => string.Equals(candidate.Name, environment.Name, StringComparison.OrdinalIgnoreCase)) + ?? environment; + + if (!string.Equals(DashboardSuiteManifestFactory.CreateEnvironmentId(aggregateEnvironment), environmentId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + for (var endpointIndex = 0; endpointIndex < environment.Endpoints.Count; endpointIndex++) + { + var endpoint = environment.Endpoints[endpointIndex]; + if (string.Equals(DashboardSuiteManifestFactory.CreateEndpointId(aggregateEnvironment, endpoint), endpointId, StringComparison.OrdinalIgnoreCase)) + { + return new SourceEntry(filePath, document, aggregateEnvironment, endpoint, environmentIndex, endpointIndex); + } + } + } + + if (document.Endpoints.Count == 0) + { + continue; + } + + var targetEnvironmentNames = ResolveTargetEnvironmentNames(document, loadedSuite.Suite); + foreach (var targetEnvironmentName in targetEnvironmentNames) + { + var aggregateEnvironment = loadedSuite.Suite.Environments + .FirstOrDefault(candidate => string.Equals(candidate.Name, targetEnvironmentName, StringComparison.OrdinalIgnoreCase)); + + if (aggregateEnvironment is null || + !string.Equals(DashboardSuiteManifestFactory.CreateEnvironmentId(aggregateEnvironment), environmentId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + for (var endpointIndex = 0; endpointIndex < document.Endpoints.Count; endpointIndex++) + { + var endpoint = document.Endpoints[endpointIndex]; + if (string.Equals(DashboardSuiteManifestFactory.CreateEndpointId(aggregateEnvironment, endpoint), endpointId, StringComparison.OrdinalIgnoreCase)) + { + return new SourceEntry(filePath, document, aggregateEnvironment, endpoint, null, endpointIndex); + } + } + } + } + + throw new InvalidOperationException("The selected endpoint could not be mapped back to a YAML file."); + } + + private static IReadOnlyList ResolveTargetEnvironmentNames( + ApiTestDocumentDefinition document, + ApiTestSuiteDefinition suite) + { + var targetEnvironmentNames = document.TargetEnvironments + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (targetEnvironmentNames.Length > 0) + { + return targetEnvironmentNames; + } + + return suite.Environments.Count == 1 + ? [suite.Environments[0].Name] + : []; + } + + private sealed record SourceEntry( + string FilePath, + ApiTestDocumentDefinition Document, + EnvironmentDefinition Environment, + EndpointDefinition Endpoint, + int? EnvironmentIndex, + int EndpointIndex); +} diff --git a/src/ApiTestRunner.App/Services/DashboardSuiteManifestFactory.cs b/src/ApiTestRunner.App/Services/DashboardSuiteManifestFactory.cs index 7c2b5a1..e5289a0 100644 --- a/src/ApiTestRunner.App/Services/DashboardSuiteManifestFactory.cs +++ b/src/ApiTestRunner.App/Services/DashboardSuiteManifestFactory.cs @@ -7,6 +7,11 @@ namespace ApiTestRunner.App.Services; public static class DashboardSuiteManifestFactory { + private static readonly System.Text.Json.JsonSerializerOptions CurlJsonSerializerOptions = new(System.Text.Json.JsonSerializerDefaults.Web) + { + WriteIndented = false + }; + public static DashboardSuiteManifest Create(ApiTestSuiteDefinition suite) { ArgumentNullException.ThrowIfNull(suite); @@ -92,11 +97,71 @@ public static string CreateTestId( testIndex.ToString()); } + public static string CreateEnvironmentId(EnvironmentDefinition environment) + { + return CreateStableId("environment", environment.Name, environment.BaseUrl); + } + + public static string CreateEndpointId(EnvironmentDefinition environment, EndpointDefinition endpoint) + { + return CreateStableId("endpoint", environment.Name, endpoint.Method, endpoint.Path, endpoint.Name); + } + + public static DashboardEndpointEditorSeed? CreateEditorSeed( + ApiTestSuiteDefinition suite, + string environmentId, + string endpointId) + { + ArgumentNullException.ThrowIfNull(suite); + + foreach (var environment in suite.Environments) + { + if (!string.Equals(CreateEnvironmentId(environment), environmentId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + foreach (var endpoint in environment.Endpoints) + { + if (!string.Equals(CreateEndpointId(environment, endpoint), endpointId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + return CreateEditorSeed(environment, endpoint, environmentId, endpointId); + } + } + + return null; + } + + public static DashboardEndpointEditorSeed CreateEditorSeed( + EnvironmentDefinition environment, + EndpointDefinition endpoint, + string environmentId, + string endpointId) + { + ArgumentNullException.ThrowIfNull(environment); + ArgumentNullException.ThrowIfNull(endpoint); + + return new DashboardEndpointEditorSeed + { + EnvironmentId = environmentId, + EnvironmentName = environment.Name, + EndpointId = endpointId, + EndpointName = endpoint.Name, + CurlCommand = BuildCurlCommand(environment, endpoint), + Tests = endpoint.Tests + .Select(CreateCurlTestDraft) + .ToArray() + }; + } + private static DashboardEnvironmentManifest BuildEnvironmentManifest(EnvironmentDefinition environment) { return new DashboardEnvironmentManifest { - Id = CreateStableId("environment", environment.Name, environment.BaseUrl), + Id = CreateEnvironmentId(environment), Name = environment.Name, BaseUrl = environment.BaseUrl, Endpoints = environment.Endpoints @@ -110,7 +175,7 @@ private static DashboardEndpointManifest BuildEndpointManifest(EnvironmentDefini { return new DashboardEndpointManifest { - Id = CreateStableId("endpoint", environment.Name, endpoint.Method, endpoint.Path, endpoint.Name), + Id = CreateEndpointId(environment, endpoint), Name = endpoint.Name, Method = endpoint.Method.ToUpperInvariant(), Path = endpoint.Path, @@ -130,4 +195,129 @@ private static string CreateStableId(params string[] parts) var raw = string.Join("|", parts); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw))); } + + private static CurlTestDraft CreateCurlTestDraft(TestDefinition test) + { + return new CurlTestDraft + { + Name = test.Name, + ExpectedStatus = test.ExpectedStatus, + Assertions = ExpandAssertionDrafts(test.Assertions) + }; + } + + private static IReadOnlyList ExpandAssertionDrafts(IReadOnlyList assertions) + { + var drafts = new List(); + + foreach (var assertion in assertions) + { + AddAssertionDraftIfPresent(drafts, assertion.Field, "equals", assertion.EqualsValue); + AddAssertionDraftIfPresent(drafts, assertion.Field, "notEquals", assertion.NotEquals); + AddAssertionDraftIfPresent(drafts, assertion.Field, "type", assertion.Type); + AddAssertionDraftIfPresent(drafts, assertion.Field, "containsText", assertion.ContainsText); + AddAssertionDraftIfPresent(drafts, assertion.Field, "startsWith", assertion.StartsWith); + AddAssertionDraftIfPresent(drafts, assertion.Field, "endsWith", assertion.EndsWith); + AddAssertionDraftIfPresent(drafts, assertion.Field, "notEmpty", assertion.NotEmpty); + AddAssertionDraftIfPresent(drafts, assertion.Field, "greaterThan", assertion.GreaterThan); + AddAssertionDraftIfPresent(drafts, assertion.Field, "greaterThanOrEqual", assertion.GreaterThanOrEqual); + AddAssertionDraftIfPresent(drafts, assertion.Field, "lessThan", assertion.LessThan); + AddAssertionDraftIfPresent(drafts, assertion.Field, "lessThanOrEqual", assertion.LessThanOrEqual); + AddAssertionDraftIfPresent(drafts, assertion.Field, "minCount", assertion.MinCount); + AddAssertionDraftIfPresent(drafts, assertion.Field, "maxCount", assertion.MaxCount); + AddAssertionDraftIfPresent(drafts, assertion.Field, "count", assertion.Count); + + if (assertion.Contains.Count > 0) + { + drafts.Add(new CurlAssertionDraft + { + Field = assertion.Field, + Rule = "contains", + Value = assertion.Contains.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase) + }); + } + } + + return drafts; + } + + private static void AddAssertionDraftIfPresent( + ICollection drafts, + string field, + string rule, + object? value) + { + if (value is null) + { + return; + } + + drafts.Add(new CurlAssertionDraft + { + Field = field, + Rule = rule, + Value = value + }); + } + + private static string BuildCurlCommand(EnvironmentDefinition environment, EndpointDefinition endpoint) + { + var command = new StringBuilder(); + command.Append("curl --request "); + command.Append(endpoint.Method.ToUpperInvariant()); + command.Append(' '); + command.Append('"'); + command.Append(EscapeForDoubleQuotedCurl(BuildRequestUrl(environment, endpoint))); + command.Append('"'); + + foreach (var header in endpoint.Headers.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)) + { + command.Append(" \\\n --header \""); + command.Append(EscapeForDoubleQuotedCurl($"{header.Key}: {header.Value}")); + command.Append('"'); + } + + if (endpoint.Body is not null) + { + command.Append(" \\\n --data \""); + command.Append(EscapeForDoubleQuotedCurl(System.Text.Json.JsonSerializer.Serialize(endpoint.Body, CurlJsonSerializerOptions))); + command.Append('"'); + } + + return command.ToString(); + } + + private static string BuildRequestUrl(EnvironmentDefinition environment, EndpointDefinition endpoint) + { + var baseUrl = environment.BaseUrl.TrimEnd('/'); + var path = endpoint.Path.StartsWith('/') ? endpoint.Path : "/" + endpoint.Path; + var url = new StringBuilder(baseUrl).Append(path); + + if (endpoint.Query.Count > 0) + { + url.Append('?'); + url.Append(string.Join("&", endpoint.Query.Select(pair => $"{pair.Key}={ConvertToCurlString(pair.Value)}"))); + } + + return url.ToString(); + } + + private static string ConvertToCurlString(object? value) + { + return value switch + { + null => string.Empty, + string text => text, + bool boolean => boolean ? "true" : "false", + IFormattable formattable => formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty, + _ => System.Text.Json.JsonSerializer.Serialize(value, CurlJsonSerializerOptions) + }; + } + + private static string EscapeForDoubleQuotedCurl(string value) + { + return value + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal); + } } diff --git a/src/ApiTestRunner.App/Services/TestRunCoordinator.cs b/src/ApiTestRunner.App/Services/TestRunCoordinator.cs index 3070fb2..054e489 100644 --- a/src/ApiTestRunner.App/Services/TestRunCoordinator.cs +++ b/src/ApiTestRunner.App/Services/TestRunCoordinator.cs @@ -9,6 +9,7 @@ namespace ApiTestRunner.App.Services; public sealed class TestRunCoordinator { private readonly IConfiguredTestSuiteProvider _suiteProvider; + private readonly DashboardEndpointEditorService _endpointEditorService; private readonly IApiTestExecutor _executor; private readonly IOptions _executionOptions; private readonly ILogger _logger; @@ -18,11 +19,13 @@ public sealed class TestRunCoordinator public TestRunCoordinator( IConfiguredTestSuiteProvider suiteProvider, + DashboardEndpointEditorService endpointEditorService, IApiTestExecutor executor, IOptions executionOptions, ILogger logger) { _suiteProvider = suiteProvider; + _endpointEditorService = endpointEditorService; _executor = executor; _executionOptions = executionOptions; _logger = logger; @@ -39,6 +42,21 @@ public async Task GetManifestAsync(CancellationToken can return DashboardSuiteManifestFactory.Create(loadedSuite.Suite); } + public async Task GetEditorSeedAsync( + string environmentId, + string endpointId, + CancellationToken cancellationToken = default) + { + return await _endpointEditorService.GetEditorSeedAsync(environmentId, endpointId, cancellationToken); + } + + public async Task SaveEditorAsync( + DashboardEndpointSaveRequest request, + CancellationToken cancellationToken = default) + { + return await _endpointEditorService.SaveAsync(request, cancellationToken); + } + public async Task ExecuteAsync( TestSelectionRequest? selectionRequest, CancellationToken cancellationToken = default) diff --git a/src/ApiTestRunner.App/Services/YamlDefinitionFormatter.cs b/src/ApiTestRunner.App/Services/YamlDefinitionFormatter.cs new file mode 100644 index 0000000..53d1b0c --- /dev/null +++ b/src/ApiTestRunner.App/Services/YamlDefinitionFormatter.cs @@ -0,0 +1,211 @@ +using System.Collections; +using System.Globalization; +using ApiTestRunner.Core.Models; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace ApiTestRunner.App.Services; + +internal static class YamlDefinitionFormatter +{ + public static string SerializeApiTestDocument(ApiTestDocumentDefinition document) + { + return SerializeObject(BuildApiTestDocument(document)); + } + + public static string SerializeObject(object value) + { + var stream = new YamlStream(new YamlDocument(BuildYamlNode(value, isKey: false))); + using var writer = new StringWriter(); + stream.Save(writer, assignAnchors: false); + return writer.ToString().Trim(); + } + + public static Dictionary BuildApiTestDocument(ApiTestDocumentDefinition document) + { + var yamlDocument = new Dictionary(); + + if (document.Environments.Count > 0) + { + yamlDocument["environments"] = document.Environments + .Select(BuildEnvironmentDocument) + .Cast() + .ToArray(); + } + + if (document.TargetEnvironments.Count > 0) + { + yamlDocument["targetEnvironments"] = document.TargetEnvironments.ToArray(); + } + + if (document.Endpoints.Count > 0) + { + yamlDocument["endpoints"] = document.Endpoints + .Select(BuildEndpointDocument) + .Cast() + .ToArray(); + } + + return yamlDocument; + } + + public static Dictionary BuildEnvironmentDocument(EnvironmentDefinition environment) + { + return new Dictionary + { + ["name"] = environment.Name, + ["baseUrl"] = environment.BaseUrl, + ["variables"] = environment.Variables.Count == 0 + ? null + : new Dictionary(environment.Variables, StringComparer.OrdinalIgnoreCase), + ["endpoints"] = environment.Endpoints.Count == 0 + ? null + : environment.Endpoints.Select(BuildEndpointDocument).Cast().ToArray() + }; + } + + public static Dictionary BuildEndpointDocument(EndpointDefinition endpoint) + { + var document = new Dictionary + { + ["name"] = endpoint.Name, + ["method"] = endpoint.Method, + ["path"] = endpoint.Path + }; + + if (endpoint.PathParams.Count > 0) + { + document["pathParams"] = new Dictionary(endpoint.PathParams, StringComparer.OrdinalIgnoreCase); + } + + if (endpoint.Query.Count > 0) + { + document["query"] = new Dictionary(endpoint.Query, StringComparer.OrdinalIgnoreCase); + } + + if (endpoint.Headers.Count > 0) + { + document["headers"] = endpoint.Headers.ToDictionary( + pair => pair.Key, + pair => (object?)pair.Value, + StringComparer.OrdinalIgnoreCase); + } + + if (endpoint.Body is not null) + { + document["body"] = endpoint.Body; + } + + if (endpoint.Tests.Count > 0) + { + document["tests"] = endpoint.Tests.Select(BuildTestDocument).Cast().ToArray(); + } + + return document; + } + + public static Dictionary BuildTestDocument(TestDefinition test) + { + var document = new Dictionary + { + ["name"] = test.Name, + ["expectedStatus"] = test.ExpectedStatus + }; + + if (test.Assertions.Count > 0) + { + document["assertions"] = test.Assertions.Select(BuildAssertionDocument).Cast().ToArray(); + } + + return document; + } + + public static Dictionary BuildAssertionDocument(AssertionDefinition assertion) + { + var document = new Dictionary + { + ["field"] = assertion.Field + }; + + AddIfPresent(document, "equals", assertion.EqualsValue); + AddIfPresent(document, "notEquals", assertion.NotEquals); + AddIfPresent(document, "type", assertion.Type); + AddIfPresent(document, "containsText", assertion.ContainsText); + AddIfPresent(document, "startsWith", assertion.StartsWith); + AddIfPresent(document, "endsWith", assertion.EndsWith); + AddIfPresent(document, "notEmpty", assertion.NotEmpty); + AddIfPresent(document, "minCount", assertion.MinCount); + AddIfPresent(document, "maxCount", assertion.MaxCount); + AddIfPresent(document, "count", assertion.Count); + AddIfPresent(document, "greaterThan", assertion.GreaterThan); + AddIfPresent(document, "greaterThanOrEqual", assertion.GreaterThanOrEqual); + AddIfPresent(document, "lessThan", assertion.LessThan); + AddIfPresent(document, "lessThanOrEqual", assertion.LessThanOrEqual); + + if (assertion.Contains.Count > 0) + { + document["contains"] = new Dictionary(assertion.Contains, StringComparer.OrdinalIgnoreCase); + } + + return document; + } + + private static void AddIfPresent(IDictionary document, string key, object? value) + { + if (value is not null) + { + document[key] = value; + } + } + + private static YamlNode BuildYamlNode(object? value, bool isKey) + { + return value switch + { + null => new YamlScalarNode("null"), + string text => new YamlScalarNode(text) + { + Style = isKey ? ScalarStyle.Plain : ScalarStyle.DoubleQuoted + }, + bool boolean => new YamlScalarNode(boolean ? "true" : "false"), + sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal + => new YamlScalarNode(Convert.ToString(value, CultureInfo.InvariantCulture)), + IDictionary dictionary => BuildMappingNode(dictionary), + IEnumerable sequence => BuildSequenceNode(sequence), + IEnumerable sequence when value is not string => BuildSequenceNode(sequence.Cast()), + _ => new YamlScalarNode(Convert.ToString(value, CultureInfo.InvariantCulture)) + { + Style = ScalarStyle.DoubleQuoted + } + }; + } + + private static YamlMappingNode BuildMappingNode(IEnumerable> values) + { + var mappingNode = new YamlMappingNode(); + + foreach (var pair in values) + { + if (pair.Value is null) + { + continue; + } + + mappingNode.Add(BuildYamlNode(pair.Key, isKey: true), BuildYamlNode(pair.Value, isKey: false)); + } + + return mappingNode; + } + + private static YamlSequenceNode BuildSequenceNode(IEnumerable values) + { + var sequenceNode = new YamlSequenceNode(); + + foreach (var item in values) + { + sequenceNode.Add(BuildYamlNode(item, isKey: false)); + } + + return sequenceNode; + } +} diff --git a/src/ApiTestRunner.App/wwwroot/app.js b/src/ApiTestRunner.App/wwwroot/app.js index d188b03..17e7494 100644 --- a/src/ApiTestRunner.App/wwwroot/app.js +++ b/src/ApiTestRunner.App/wwwroot/app.js @@ -157,7 +157,7 @@ function renderSelection(manifest) { const visibleEnvironmentTestIds = endpoints.flatMap((endpointEntry) => endpointEntry.tests.map((test) => test.id)); const environmentNode = document.createElement("details"); - environmentNode.className = "selection-group"; + environmentNode.className = "card card-outline card-success selection-group"; environmentNode.open = selectionSearchTerm ? true : selectionExpansionState.environments.get(environment.id) ?? true; environmentNode.addEventListener("toggle", () => { selectionExpansionState.environments.set(environment.id, environmentNode.open); @@ -175,14 +175,14 @@ function renderSelection(manifest) { )); const environmentBody = document.createElement("div"); - environmentBody.className = "selection-group-body"; + environmentBody.className = "card-body selection-group-body"; for (const endpointEntry of endpoints) { const { endpoint, endpointMatches, tests } = endpointEntry; const endpointIds = tests.map((test) => test.id); const endpointNode = document.createElement("details"); - endpointNode.className = "selection-subgroup"; + endpointNode.className = "card card-outline card-success selection-subgroup"; endpointNode.open = selectionSearchTerm ? true : selectionExpansionState.endpoints.get(endpoint.id) ?? false; endpointNode.addEventListener("toggle", () => { selectionExpansionState.endpoints.set(endpoint.id, endpointNode.open); @@ -196,18 +196,24 @@ function renderSelection(manifest) { ? `${highlightMatch(endpoint.method, selectionSearchTerm)} ${highlightMatch(endpoint.path, selectionSearchTerm)} - ${tests.length} tests` : `${highlightMatch(endpoint.method, selectionSearchTerm)} ${highlightMatch(endpoint.path, selectionSearchTerm)} - ${tests.length} matching tests`, endpointIds, - toggleGroupSelection + toggleGroupSelection, + { + iconClass: "fa-solid fa-pen-to-square", + label: "Edit endpoint", + onClick: () => openEndpointEditor(environment.id, endpoint.id) + } )); const testList = document.createElement("div"); - testList.className = "selection-test-list"; + testList.className = "list-group selection-test-list"; for (const test of tests) { const testRow = document.createElement("label"); - testRow.className = "selection-test"; + testRow.className = "list-group-item selection-test"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; + checkbox.className = "form-check-input"; checkbox.checked = selectedTestIds.has(test.id); checkbox.addEventListener("change", () => toggleTestSelection(test.id, checkbox.checked)); @@ -231,12 +237,13 @@ function renderSelection(manifest) { } } -function createSelectionHeader(titleHtml, detailHtml, childTestIds, onToggle) { +function createSelectionHeader(titleHtml, detailHtml, childTestIds, onToggle, action = null) { const header = document.createElement("div"); header.className = "selection-header-row"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; + checkbox.className = "form-check-input"; checkbox.checked = childTestIds.length > 0 && childTestIds.every((testId) => selectedTestIds.has(testId)); checkbox.indeterminate = !checkbox.checked && childTestIds.some((testId) => selectedTestIds.has(testId)); checkbox.addEventListener("click", (event) => { @@ -250,6 +257,20 @@ function createSelectionHeader(titleHtml, detailHtml, childTestIds, onToggle) { header.appendChild(checkbox); header.appendChild(labelStack); + + if (action) { + const actionButton = document.createElement("button"); + actionButton.type = "button"; + actionButton.className = "btn btn-outline-primary btn-sm selection-action-button"; + actionButton.innerHTML = `${action.label}`; + actionButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + action.onClick(); + }); + header.appendChild(actionButton); + } + return header; } @@ -275,6 +296,15 @@ function toggleTestSelection(testId, isChecked) { renderSelection(suiteManifest); } +function openEndpointEditor(environmentId, endpointId) { + const query = new URLSearchParams({ + environmentId, + endpointId + }); + + window.location.href = `/curl-import.html?${query.toString()}`; +} + function getAllTestIds(manifest) { return manifest.environments.flatMap((environment) => environment.endpoints.flatMap((endpoint) => endpoint.tests.map((test) => test.id)) @@ -426,7 +456,7 @@ function renderState(state) { const environmentBadge = environmentNode.querySelector(".environment-badge"); const environmentIsPassing = (resultsSearchTerm || resultsStatusFilter !== "all") ? visibleFailedTests === 0 : environment.failedTests === 0; environmentBadge.textContent = environmentIsPassing ? "Passing" : "Issues"; - environmentBadge.className = `environment-badge ${environmentIsPassing ? "passing" : "failing"}`; + environmentBadge.className = buildStatusBadgeClass("environment-badge", environmentIsPassing); const endpointList = environmentNode.querySelector(".endpoint-list"); @@ -447,7 +477,7 @@ function renderState(state) { const endpointVisibleFailedTests = tests.filter((test) => !test.isSuccess).length; const endpointIsPassing = (resultsSearchTerm || resultsStatusFilter !== "all") ? endpointVisibleFailedTests === 0 : endpoint.isSuccess; endpointBadge.textContent = endpointIsPassing ? "Pass" : "Fail"; - endpointBadge.className = `endpoint-badge ${endpointIsPassing ? "passing" : "failing"}`; + endpointBadge.className = buildStatusBadgeClass("environment-badge endpoint-badge", endpointIsPassing); initializeResponsePreview( endpointNode, @@ -468,7 +498,7 @@ function renderState(state) { const testBadge = testNode.querySelector(".test-badge"); testBadge.textContent = test.isSuccess ? "Pass" : "Fail"; - testBadge.className = `test-badge ${test.isSuccess ? "passing" : "failing"}`; + testBadge.className = buildStatusBadgeClass("environment-badge test-badge", test.isSuccess); const expectedText = `Expected ${test.expectedStatus}, actual ${test.actualStatus ?? "n/a"}`; const errorSuffix = test.errorMessage ? ` - ${test.errorMessage}` : ""; @@ -737,15 +767,26 @@ function updateResultButtons(isEnabled) { } function updateResultFilterButtons() { - showAllResultsButton.classList.toggle("is-active", resultsStatusFilter === "all"); - showPassingResultsButton.classList.toggle("is-active", resultsStatusFilter === "passing"); - showFailingResultsButton.classList.toggle("is-active", resultsStatusFilter === "failing"); + setFilterButtonState(showAllResultsButton, resultsStatusFilter === "all", "btn-primary"); + setFilterButtonState(showPassingResultsButton, resultsStatusFilter === "passing", "btn-success"); + setFilterButtonState(showFailingResultsButton, resultsStatusFilter === "failing", "btn-danger"); } function setStatusError(hasError) { document.getElementById("runStatus").classList.toggle("status-error", hasError); } +function setFilterButtonState(button, isActive, activeClass) { + button.classList.toggle("is-active", isActive); + button.classList.toggle(activeClass, isActive); + button.classList.toggle("text-white", isActive); + button.classList.toggle("btn-default", !isActive); +} + +function buildStatusBadgeClass(baseClassName, isPassing) { + return `${baseClassName} badge rounded-pill ${isPassing ? "text-bg-success" : "text-bg-danger"}`; +} + function getResultEnvironmentKey(environment) { return `${environment.name}|${environment.baseUrl}`; } diff --git a/src/ApiTestRunner.App/wwwroot/curl-import.html b/src/ApiTestRunner.App/wwwroot/curl-import.html index b41374e..3b12125 100644 --- a/src/ApiTestRunner.App/wwwroot/curl-import.html +++ b/src/ApiTestRunner.App/wwwroot/curl-import.html @@ -8,7 +8,7 @@ - +