Skip to content

Commit 8d345bf

Browse files
authored
Merge branch 'main' into feature/211531-complex-fields
2 parents 45e8108 + 196a8cb commit 8d345bf

17 files changed

Lines changed: 822 additions & 129 deletions

src/DfE.ExternalApplications.Application/DfE.ExternalApplications.Application.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
<ItemGroup>
1010
<PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="2.3.0" />
11+
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.3.0" />
1112
</ItemGroup>
1213

1314
<ItemGroup>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using DfE.ExternalApplications.Domain.Models;
2+
3+
namespace DfE.ExternalApplications.Application.Interfaces;
4+
5+
/// <summary>
6+
/// Provides parsing capabilities for API error responses
7+
/// </summary>
8+
public interface IApiErrorParser
9+
{
10+
/// <summary>
11+
/// Parses an exception to extract structured API error information
12+
/// </summary>
13+
/// <param name="exception">The exception containing the API error</param>
14+
/// <returns>Parsed API error information</returns>
15+
ApiErrorParsingResult ParseApiError(Exception exception);
16+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using DfE.ExternalApplications.Domain.Models;
2+
using Microsoft.AspNetCore.Mvc.ModelBinding;
3+
4+
namespace DfE.ExternalApplications.Application.Interfaces;
5+
6+
/// <summary>
7+
/// Handles mapping of API errors to model state validation errors
8+
/// </summary>
9+
public interface IModelStateErrorHandler
10+
{
11+
/// <summary>
12+
/// Adds API validation errors to the model state with proper field mapping
13+
/// </summary>
14+
/// <param name="modelState">The model state to add errors to</param>
15+
/// <param name="apiError">The parsed API error response</param>
16+
/// <param name="fieldMappings">Optional mapping of API field names to model property names</param>
17+
void AddApiErrorsToModelState(ModelStateDictionary modelState, ApiErrorResponse apiError,
18+
Dictionary<string, string>? fieldMappings = null);
19+
20+
/// <summary>
21+
/// Adds a general error message to the model state
22+
/// </summary>
23+
/// <param name="modelState">The model state to add the error to</param>
24+
/// <param name="errorMessage">The error message to add</param>
25+
void AddGeneralError(ModelStateDictionary modelState, string errorMessage);
26+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using System.Text.Json.Serialization;
3+
4+
namespace DfE.ExternalApplications.Domain.Models;
5+
6+
[ExcludeFromCodeCoverage]
7+
public class ApiErrorResponse
8+
{
9+
[JsonPropertyName("type")]
10+
public string? Type { get; set; }
11+
12+
[JsonPropertyName("title")]
13+
public string? Title { get; set; }
14+
15+
[JsonPropertyName("status")]
16+
public int Status { get; set; }
17+
18+
[JsonPropertyName("errors")]
19+
public Dictionary<string, string[]>? Errors { get; set; }
20+
21+
public bool HasValidationErrors => Errors?.Any() == true;
22+
}
23+
24+
[ExcludeFromCodeCoverage]
25+
public class ApiErrorParsingResult
26+
{
27+
public bool IsSuccess { get; init; }
28+
public ApiErrorResponse? ErrorResponse { get; init; }
29+
public string? RawError { get; init; }
30+
31+
public static ApiErrorParsingResult Success(ApiErrorResponse errorResponse) => new()
32+
{
33+
IsSuccess = true,
34+
ErrorResponse = errorResponse
35+
};
36+
37+
public static ApiErrorParsingResult Failure(string rawError) => new()
38+
{
39+
IsSuccess = false,
40+
RawError = rawError
41+
};
42+
}

src/DfE.ExternalApplications.Infrastructure/DfE.ExternalApplications.Infrastructure.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99
<ItemGroup>
1010
<PackageReference Include="DfE.CoreLibs.Caching" Version="1.0.10" />
11-
<PackageReference Include="DfE.CoreLibs.Contracts" Version="1.0.25-prerelease-8" />
12-
<PackageReference Include="GovUK.Dfe.ExternalApplications.Api.Client" Version="0.1.4" />
11+
<PackageReference Include="DfE.CoreLibs.Contracts" Version="1.0.26" />
12+
<PackageReference Include="GovUK.Dfe.ExternalApplications.Api.Client" Version="0.1.6" />
1313
<PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.3.0" />
1414
</ItemGroup>
1515

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
using DfE.ExternalApplications.Application.Interfaces;
2+
using DfE.ExternalApplications.Domain.Models;
3+
using Microsoft.Extensions.Logging;
4+
using System.Diagnostics.CodeAnalysis;
5+
using System.Text.Json;
6+
using System.Text.RegularExpressions;
7+
8+
namespace DfE.ExternalApplications.Infrastructure.Services;
9+
10+
[ExcludeFromCodeCoverage]
11+
public class ApiErrorParser(ILogger<ApiErrorParser> logger) : IApiErrorParser
12+
{
13+
private static readonly JsonSerializerOptions JsonOptions = new()
14+
{
15+
PropertyNameCaseInsensitive = true,
16+
AllowTrailingCommas = true,
17+
ReadCommentHandling = JsonCommentHandling.Skip
18+
};
19+
20+
private static readonly Regex[] JsonExtractionPatterns =
21+
[
22+
new(@"\{""type"".*?\}", RegexOptions.Singleline | RegexOptions.Compiled),
23+
new(@"\{.*?""errors"".*?\}", RegexOptions.Singleline | RegexOptions.Compiled),
24+
new(@"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", RegexOptions.Compiled)
25+
];
26+
27+
public ApiErrorParsingResult ParseApiError(Exception exception)
28+
{
29+
logger.LogDebug("Parsing API error from exception: {ExceptionType}", exception.GetType().Name);
30+
31+
var errorSources = GetErrorSources(exception);
32+
33+
foreach (var source in errorSources)
34+
{
35+
var jsonContent = ExtractJsonFromString(source);
36+
if (jsonContent != null)
37+
{
38+
var parseResult = TryParseApiError(jsonContent);
39+
if (parseResult != null)
40+
{
41+
logger.LogDebug("Successfully parsed API error with {ErrorCount} validation errors",
42+
parseResult.Errors?.Count ?? 0);
43+
return ApiErrorParsingResult.Success(parseResult);
44+
}
45+
}
46+
}
47+
48+
// Fallback: try to extract specific error patterns
49+
var fallbackErrors = ExtractKnownErrorPatterns(exception.ToString());
50+
if (fallbackErrors.Count > 0)
51+
{
52+
var fallbackResponse = new ApiErrorResponse
53+
{
54+
Errors = fallbackErrors,
55+
Title = "Validation errors occurred",
56+
Status = 400
57+
};
58+
59+
logger.LogDebug("Used fallback error extraction, found {ErrorCount} errors", fallbackErrors.Count);
60+
return ApiErrorParsingResult.Success(fallbackResponse);
61+
}
62+
63+
logger.LogDebug("Could not parse structured API error, returning raw message");
64+
return ApiErrorParsingResult.Failure(exception.Message);
65+
}
66+
67+
private static IEnumerable<string> GetErrorSources(Exception exception)
68+
{
69+
yield return exception.Message;
70+
71+
if (exception.InnerException != null)
72+
yield return exception.InnerException.Message;
73+
74+
yield return exception.ToString();
75+
}
76+
77+
private string? ExtractJsonFromString(string input)
78+
{
79+
if (string.IsNullOrWhiteSpace(input))
80+
return null;
81+
82+
// Try regex patterns first
83+
foreach (var pattern in JsonExtractionPatterns)
84+
{
85+
var match = pattern.Match(input);
86+
if (match.Success && IsValidJson(match.Value))
87+
{
88+
return match.Value;
89+
}
90+
}
91+
92+
// Fallback to brace counting
93+
return ExtractJsonByBraceMatching(input);
94+
}
95+
96+
private static string? ExtractJsonByBraceMatching(string input)
97+
{
98+
var startIndex = input.IndexOf('{');
99+
if (startIndex == -1) return null;
100+
101+
var braceCount = 0;
102+
for (int i = startIndex; i < input.Length; i++)
103+
{
104+
if (input[i] == '{') braceCount++;
105+
else if (input[i] == '}') braceCount--;
106+
107+
if (braceCount == 0)
108+
{
109+
var candidate = input.Substring(startIndex, i - startIndex + 1);
110+
return IsValidJson(candidate) ? candidate : null;
111+
}
112+
}
113+
114+
return null;
115+
}
116+
117+
private ApiErrorResponse? TryParseApiError(string jsonContent)
118+
{
119+
try
120+
{
121+
return JsonSerializer.Deserialize<ApiErrorResponse>(jsonContent, JsonOptions);
122+
}
123+
catch (JsonException ex)
124+
{
125+
logger.LogDebug(ex, "Failed to deserialize JSON as ApiErrorResponse");
126+
return null;
127+
}
128+
}
129+
130+
private static bool IsValidJson(string content)
131+
{
132+
try
133+
{
134+
JsonSerializer.Deserialize<object>(content);
135+
return true;
136+
}
137+
catch
138+
{
139+
return false;
140+
}
141+
}
142+
143+
private static Dictionary<string, string[]> ExtractKnownErrorPatterns(string exceptionText)
144+
{
145+
var errors = new Dictionary<string, string[]>();
146+
147+
// Pattern for VersionNumber errors
148+
if (exceptionText.Contains("VersionNumber", StringComparison.OrdinalIgnoreCase))
149+
{
150+
var versionMatch = Regex.Match(exceptionText,
151+
@"VersionNumber["":\[\]]*([^""}\]]+)",
152+
RegexOptions.IgnoreCase);
153+
154+
if (versionMatch.Success)
155+
{
156+
var errorMessage = versionMatch.Groups[1].Value.Trim();
157+
if (!string.IsNullOrEmpty(errorMessage))
158+
{
159+
errors["VersionNumber"] = [errorMessage];
160+
}
161+
}
162+
else if (exceptionText.Contains("Invalid Template Version format", StringComparison.OrdinalIgnoreCase))
163+
{
164+
errors["VersionNumber"] = ["Invalid Template Version format."];
165+
}
166+
}
167+
168+
// Pattern for JsonSchema errors
169+
if (exceptionText.Contains("JsonSchema", StringComparison.OrdinalIgnoreCase))
170+
{
171+
var schemaMatch = Regex.Match(exceptionText,
172+
@"JsonSchema["":\[\]]*([^""}\]]+)",
173+
RegexOptions.IgnoreCase);
174+
175+
if (schemaMatch.Success)
176+
{
177+
var errorMessage = schemaMatch.Groups[1].Value.Trim();
178+
if (!string.IsNullOrEmpty(errorMessage))
179+
{
180+
errors["JsonSchema"] = [errorMessage];
181+
}
182+
}
183+
}
184+
185+
return errors;
186+
}
187+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using DfE.ExternalApplications.Application.Interfaces;
2+
using DfE.ExternalApplications.Domain.Models;
3+
using Microsoft.AspNetCore.Mvc.ModelBinding;
4+
using Microsoft.Extensions.Logging;
5+
using System.Diagnostics.CodeAnalysis;
6+
7+
namespace DfE.ExternalApplications.Infrastructure.Services;
8+
9+
[ExcludeFromCodeCoverage]
10+
public class ModelStateErrorHandler(ILogger<ModelStateErrorHandler> logger) : IModelStateErrorHandler
11+
{
12+
public void AddApiErrorsToModelState(ModelStateDictionary modelState, ApiErrorResponse apiError,
13+
Dictionary<string, string>? fieldMappings = null)
14+
{
15+
if (!apiError.HasValidationErrors)
16+
{
17+
logger.LogDebug("No validation errors found in API error response");
18+
return;
19+
}
20+
21+
foreach (var errorField in apiError.Errors!)
22+
{
23+
var fieldName = errorField.Key;
24+
var fieldErrors = errorField.Value;
25+
26+
// Map API field names to model property names
27+
var modelFieldName = GetMappedFieldName(fieldName, fieldMappings);
28+
29+
foreach (var errorMessage in fieldErrors)
30+
{
31+
if (string.IsNullOrEmpty(modelFieldName))
32+
{
33+
// Add as general error with field context
34+
modelState.AddModelError(string.Empty, $"{fieldName}: {errorMessage}");
35+
logger.LogDebug("Added general error for unmapped field {FieldName}: {ErrorMessage}",
36+
fieldName, errorMessage);
37+
}
38+
else
39+
{
40+
// Add as field-specific error
41+
modelState.AddModelError(modelFieldName, errorMessage);
42+
logger.LogDebug("Added field error for {ModelField} (API field: {ApiField}): {ErrorMessage}",
43+
modelFieldName, fieldName, errorMessage);
44+
}
45+
}
46+
}
47+
}
48+
49+
public void AddGeneralError(ModelStateDictionary modelState, string errorMessage)
50+
{
51+
modelState.AddModelError(string.Empty, errorMessage);
52+
logger.LogDebug("Added general error: {ErrorMessage}", errorMessage);
53+
}
54+
55+
private static string? GetMappedFieldName(string apiFieldName, Dictionary<string, string>? fieldMappings)
56+
{
57+
if (fieldMappings?.TryGetValue(apiFieldName, out var mappedName) == true)
58+
{
59+
return mappedName;
60+
}
61+
62+
// Default mappings for common API field names
63+
return apiFieldName switch
64+
{
65+
"VersionNumber" => "NewVersion",
66+
"JsonSchema" => "NewSchema",
67+
_ => null // Unmapped fields will be added as general errors
68+
};
69+
}
70+
}

src/DfE.ExternalApplications.Web/DfE.ExternalApplications.Web.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
<ItemGroup>
1111
<PackageReference Include="DfE.CoreLibs.Caching" Version="1.0.10" />
12-
<PackageReference Include="GovUK.Dfe.ExternalApplications.Api.Client" Version="0.1.4" />
12+
<PackageReference Include="GovUK.Dfe.ExternalApplications.Api.Client" Version="0.1.6" />
1313
<PackageReference Include="GovUk.Frontend.AspNetCore" Version="2.8.1" />
1414
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
1515
<PackageReference Include="DfE.CoreLibs.Security" Version="1.1.13" />

0 commit comments

Comments
 (0)