| | | 1 | | using System.Diagnostics.CodeAnalysis; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | using System.Text.Json.Serialization.Metadata; |
| | | 4 | | |
| | | 5 | | namespace AsyncResponse; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// Defensive JSON helpers for broker ingress payloads. All overloads resolve contract metadata |
| | | 9 | | /// through <see cref="AsyncResponseJson"/> (trim/AOT-safe) instead of the reflection-based |
| | | 10 | | /// serializer entry points; property matching stays case-insensitive as it always was here. |
| | | 11 | | /// </summary> |
| | | 12 | | internal static class JsonSafety |
| | | 13 | | { |
| | | 14 | | /// <summary> |
| | | 15 | | /// Deserializes with guards for the classic broker-ingress garbage: empty bodies and HTML |
| | | 16 | | /// error pages. Throws <see cref="InvalidDataException"/> with the offending prefix so the |
| | | 17 | | /// failure is diagnosable from logs. |
| | | 18 | | /// </summary> |
| | | 19 | | public static T? SafeDeserialize<T>(string json, JsonSerializerOptions? options = null) |
| | 3 | 20 | | => SafeDeserialize(json, AsyncResponseJson.GetTypeInfo<T>(WithResolver(options))); |
| | | 21 | | |
| | | 22 | | /// <summary>Deserializes with the ingress guards using pre-resolved contract metadata.</summary> |
| | | 23 | | public static T? SafeDeserialize<T>(string json, JsonTypeInfo<T> typeInfo) |
| | | 24 | | { |
| | 3 | 25 | | ThrowIfClearlyNotJson(json); |
| | | 26 | | |
| | | 27 | | try |
| | | 28 | | { |
| | 3 | 29 | | return JsonSerializer.Deserialize(json, typeInfo); |
| | | 30 | | } |
| | 3 | 31 | | catch (JsonException jsonException) |
| | | 32 | | { |
| | | 33 | | // Re-throw with the payload prefix in the message so the failure is diagnosable. |
| | 3 | 34 | | throw new InvalidDataException($"Failed to parse JSON payload: {json[..Math.Min(200, json.Length)]}…", jsonE |
| | | 35 | | } |
| | 3 | 36 | | } |
| | | 37 | | |
| | | 38 | | /// <summary> |
| | | 39 | | /// Non-generic counterpart for callers that only know the target type at runtime (e.g. |
| | | 40 | | /// materializing a persisted flow input). |
| | | 41 | | /// </summary> |
| | | 42 | | public static object? SafeDeserialize(string json, Type returnType, JsonSerializerOptions? options = null) |
| | | 43 | | { |
| | 3 | 44 | | ThrowIfClearlyNotJson(json); |
| | | 45 | | |
| | | 46 | | try |
| | | 47 | | { |
| | 2 | 48 | | return JsonSerializer.Deserialize(json, AsyncResponseJson.GetTypeInfo(returnType, WithResolver(options))); |
| | | 49 | | } |
| | 2 | 50 | | catch (JsonException jsonException) |
| | | 51 | | { |
| | | 52 | | // Re-throw with the payload prefix in the message so the failure is diagnosable. |
| | 2 | 53 | | throw new InvalidDataException($"Failed to parse JSON payload: {json[..Math.Min(200, json.Length)]}…", jsonE |
| | | 54 | | } |
| | 2 | 55 | | } |
| | | 56 | | |
| | | 57 | | /// <summary> |
| | | 58 | | /// Defaults to the library's case-insensitive chain options. Caller-supplied options are |
| | | 59 | | /// honored exactly as the reflection-based overloads honored them: an instance with no |
| | | 60 | | /// resolver gets the runtime's default reflection resolver bound (that is what |
| | | 61 | | /// <c>JsonSerializer.Deserialize(json, options)</c> used to do on first use), which throws at |
| | | 62 | | /// runtime when the app disabled reflection-based serialization — same as before, but without |
| | | 63 | | /// carrying IL2026/IL3050. |
| | | 64 | | /// </summary> |
| | | 65 | | private static JsonSerializerOptions WithResolver(JsonSerializerOptions? options) |
| | | 66 | | { |
| | 3 | 67 | | if (options is null) |
| | 3 | 68 | | return AsyncResponseJson.CaseInsensitive; |
| | | 69 | | |
| | | 70 | | // When reflection is unavailable (trimmed/AOT) the resolver stays null and GetTypeInfo |
| | | 71 | | // surfaces the actionable register-a-context error instead. |
| | 3 | 72 | | if (options.TypeInfoResolver is null && JsonSerializer.IsReflectionEnabledByDefault) |
| | 2 | 73 | | PopulateReflectionResolver(options); |
| | | 74 | | |
| | 2 | 75 | | return options; |
| | | 76 | | |
| | | 77 | | [UnconditionalSuppressMessage("Trimming", "IL2026", |
| | | 78 | | Justification = "Reachable only when JsonSerializer.IsReflectionEnabledByDefault is true; trimmed and AOT bu |
| | | 79 | | [UnconditionalSuppressMessage("AOT", "IL3050", |
| | | 80 | | Justification = "Same guard: never reached under Native AOT.")] |
| | | 81 | | static void PopulateReflectionResolver(JsonSerializerOptions options) |
| | 3 | 82 | | => options.MakeReadOnly(populateMissingResolver: true); |
| | | 83 | | } |
| | | 84 | | |
| | | 85 | | /// <summary>Runs the ThrowIfClearlyNotJson operation.</summary> |
| | | 86 | | public static void ThrowIfClearlyNotJson(string json) |
| | | 87 | | { |
| | 3 | 88 | | if (string.IsNullOrWhiteSpace(json)) |
| | 3 | 89 | | throw new InvalidDataException("Empty message body when JSON was expected."); |
| | | 90 | | |
| | 3 | 91 | | var trimmed = json.AsSpan().TrimStart(); |
| | | 92 | | |
| | | 93 | | // Guard against HTML error pages. |
| | 3 | 94 | | if (trimmed[0] == '<') |
| | 3 | 95 | | throw new InvalidDataException($"Received HTML when JSON was expected: {json[..Math.Min(200, json.Length)]}… |
| | 3 | 96 | | } |
| | | 97 | | } |