diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a3b5a622b5..d721cac287 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -128,7 +128,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 - name: Cache node_modules uses: actions/cache@v4 diff --git a/build/generate-client.sh b/build/generate-client.sh index 30c64780db..ce59fcb363 100644 --- a/build/generate-client.sh +++ b/build/generate-client.sh @@ -1,7 +1,7 @@ curl -X POST https://generator3.swagger.io/api/generate \ -H 'content-type: application/json' \ -d '{ - "specURL" : "https://collector.exceptionless.io/docs/v2/swagger.json", + "specURL" : "https://collector.exceptionless.io/docs/v2/openapi.json", "lang" : "typescript-fetch", "type" : "CLIENT", "options" : { @@ -11,4 +11,4 @@ curl -X POST https://generator3.swagger.io/api/generate \ } }, "codegenVersion" : "V3" - }' --output exceptionless-ts.zip \ No newline at end of file + }' --output exceptionless-ts.zip diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 2c09089c48..aa201b1183 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -1,4 +1,6 @@ -using AutoMapper; +using System.Text.Json; +using System.Text.Json.Serialization; +using AutoMapper; using Exceptionless.Core.Authentication; using Exceptionless.Core.Billing; using Exceptionless.Core.Configuration; @@ -44,8 +46,6 @@ using Foundatio.Storage; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; using DataDictionary = Exceptionless.Core.Models.DataDictionary; using MaintainIndexesJob = Foundatio.Repositories.Elasticsearch.Jobs.MaintainIndexesJob; @@ -55,32 +55,39 @@ public class Bootstrapper { public static void RegisterServices(IServiceCollection services, AppOptions appOptions) { - JsonConvert.DefaultSettings = () => new JsonSerializerSettings + // PERF: Word towards getting rid of JSON.NET. + Newtonsoft.Json.JsonConvert.DefaultSettings = () => new Newtonsoft.Json.JsonSerializerSettings { - DateParseHandling = DateParseHandling.DateTimeOffset + DateParseHandling = Newtonsoft.Json.DateParseHandling.DateTimeOffset }; - services.AddSingleton(_ => GetJsonContractResolver()); - services.AddSingleton(s => + services.AddSingleton(_ => GetJsonContractResolver()); + services.AddSingleton(s => { // NOTE: These settings may need to be synced in the Elastic Configuration. - var settings = new JsonSerializerSettings + var settings = new Newtonsoft.Json.JsonSerializerSettings { - MissingMemberHandling = MissingMemberHandling.Ignore, - DateParseHandling = DateParseHandling.DateTimeOffset, - ContractResolver = s.GetRequiredService() + MissingMemberHandling = Newtonsoft.Json.MissingMemberHandling.Ignore, + DateParseHandling = Newtonsoft.Json.DateParseHandling.DateTimeOffset, + ContractResolver = s.GetRequiredService() }; settings.AddModelConverters(s.GetRequiredService>()); return settings; }); + services.AddSingleton(_ => new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = LowerCaseUnderscoreNamingPolicy.Instance, + Converters = { new ObjectToInferredTypesConverter() } + }); + + services.AddSingleton(s => s.GetRequiredService()); + services.AddSingleton(s => new SystemTextJsonSerializer(s.GetRequiredService())); + services.ReplaceSingleton(_ => TimeProvider.System); services.AddSingleton(); - services.AddSingleton(s => JsonSerializer.Create(s.GetRequiredService())); - services.AddSingleton(s => new JsonNetSerializer(s.GetRequiredService())); - services.AddSingleton(s => new JsonNetSerializer(s.GetRequiredService())); - services.AddSingleton(s => new InMemoryCacheClient(new InMemoryCacheClientOptions { CloneValues = true, diff --git a/src/Exceptionless.Core/Configuration/AppOptions.cs b/src/Exceptionless.Core/Configuration/AppOptions.cs index 02de4342ef..19f94e37ac 100644 --- a/src/Exceptionless.Core/Configuration/AppOptions.cs +++ b/src/Exceptionless.Core/Configuration/AppOptions.cs @@ -1,9 +1,8 @@ using System.Diagnostics; +using System.Text.Json.Serialization; using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; using Microsoft.Extensions.Configuration; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; namespace Exceptionless.Core; @@ -26,7 +25,7 @@ public class AppOptions /// public string? ExceptionlessServerUrl { get; internal set; } - [JsonConverter(typeof(StringEnumConverter))] + [JsonConverter(typeof(JsonStringEnumConverter))] public AppMode AppMode { get; internal set; } public string AppScope { get; internal set; } = null!; diff --git a/src/Exceptionless.Core/Configuration/EmailOptions.cs b/src/Exceptionless.Core/Configuration/EmailOptions.cs index 17d560d967..0fe9fa69ef 100644 --- a/src/Exceptionless.Core/Configuration/EmailOptions.cs +++ b/src/Exceptionless.Core/Configuration/EmailOptions.cs @@ -1,8 +1,7 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json.Serialization; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Utility; using Microsoft.Extensions.Configuration; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; namespace Exceptionless.Core.Configuration; @@ -26,7 +25,7 @@ public class EmailOptions public int SmtpPort { get; internal set; } - [JsonConverter(typeof(StringEnumConverter))] + [JsonConverter(typeof(JsonStringEnumConverter))] public SmtpEncryption SmtpEncryption { get; internal set; } public string? SmtpUser { get; internal set; } diff --git a/src/Exceptionless.Core/Extensions/DataDictionaryExtensions.cs b/src/Exceptionless.Core/Extensions/DataDictionaryExtensions.cs index 89b09bbdcb..7c8f196b53 100644 --- a/src/Exceptionless.Core/Extensions/DataDictionaryExtensions.cs +++ b/src/Exceptionless.Core/Extensions/DataDictionaryExtensions.cs @@ -1,12 +1,35 @@ -using Exceptionless.Core.Models; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using Exceptionless.Core.Models; namespace Exceptionless.Core.Extensions; public static class DataDictionaryExtensions { - public static T? GetValue(this DataDictionary extendedData, string key) + /// + /// Retrieves a typed value from the , deserializing if necessary. + /// + /// The target type to deserialize to. + /// The data dictionary containing the value. + /// The key of the value to retrieve. + /// The JSON serializer options to use for deserialization. + /// The deserialized value, or default if deserialization fails. + /// Thrown when the key is not found in the dictionary. + /// + /// This method handles multiple source formats in priority order: + /// + /// Direct type match - returns value directly + /// - extracts root element and deserializes + /// - deserializes using provided options + /// - deserializes using provided options + /// - re-serializes to JSON then deserializes (for ObjectToInferredTypesConverter output) + /// of objects - re-serializes to JSON then deserializes + /// - uses ToObject for Elasticsearch compatibility (data read from Elasticsearch uses JSON.NET) + /// JSON string - parses and deserializes + /// Fallback - attempts type conversion via ToType + /// + /// + public static T? GetValue(this DataDictionary extendedData, string key, JsonSerializerOptions options) { if (!extendedData.TryGetValue(key, out object? data)) throw new KeyNotFoundException($"Key \"{key}\" not found in the dictionary."); @@ -14,24 +37,96 @@ public static class DataDictionaryExtensions if (data is T value) return value; - if (data is JObject jObject) + // JsonDocument -> JsonElement + if (data is JsonDocument jsonDocument) + data = jsonDocument.RootElement; + + // JsonElement (from STJ deserialization when ObjectToInferredTypesConverter wasn't used) + if (data is JsonElement jsonElement) + { + if (TryDeserialize(jsonElement, options, out T? result)) + return result; + } + + // JsonNode (JsonObject/JsonArray/JsonValue) + if (data is JsonNode jsonNode) + { + try + { + var result = jsonNode.Deserialize(options); + if (result is not null) + return result; + } + catch + { + // Ignored - fall through to next handler + } + } + + // Dictionary from ObjectToInferredTypesConverter + // Re-serialize to JSON then deserialize to target type with proper naming policy + if (data is Dictionary dictionary) + { + try + { + string dictJson = JsonSerializer.Serialize(dictionary, options); + var result = JsonSerializer.Deserialize(dictJson, options); + if (result is not null) + return result; + } + catch + { + // Ignored - fall through to next handler + } + } + + // List from ObjectToInferredTypesConverter (for array values) + if (data is List list) + { + try + { + string listJson = JsonSerializer.Serialize(list, options); + var result = JsonSerializer.Deserialize(listJson, options); + if (result is not null) + return result; + } + catch + { + // Ignored - fall through to next handler + } + } + + // Newtonsoft.Json.Linq.JObject - for Elasticsearch compatibility. + // When data is read from Elasticsearch (which uses JSON.NET via NEST), complex objects + // in DataDictionary are deserialized as JObject. This handler converts them to the target type. + if (data is Newtonsoft.Json.Linq.JObject jObject) { try { return jObject.ToObject(); } - catch { } + catch + { + // Ignored - fall through to next handler + } } + // JSON string if (data is string json && json.IsJson()) { try { - return JsonConvert.DeserializeObject(json); + var result = JsonSerializer.Deserialize(json, options); + if (result is not null) + return result; + } + catch + { + // Ignored - fall through to next handler } - catch { } } + // Fallback: attempt direct type conversion try { if (data != null) @@ -39,14 +134,57 @@ public static class DataDictionaryExtensions return data.ToType(); } } - catch { } + catch + { + // Ignored + } return default; } + private static bool TryDeserialize(JsonElement element, JsonSerializerOptions options, out T? result) + { + result = default; + + try + { + // Fast-path for common primitives where the element isn't an object/array + // (Deserialize also works for these, but this avoids some edge cases and allocations) + if (typeof(T) == typeof(string)) + { + object? s = element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => null, + _ => element.GetRawText() + }; + + result = (T?)s; + return true; + } + + // General case + var deserialized = element.Deserialize(options); + if (deserialized is not null) + { + result = deserialized; + return true; + } + } + catch + { + // Ignored + } + + return false; + } + public static void RemoveSensitiveData(this DataDictionary extendedData) { - string[] removeKeys = extendedData.Keys.Where(k => k.StartsWith("-")).ToArray(); + string[] removeKeys = extendedData.Keys.Where(k => k.StartsWith('-')).ToArray(); foreach (string key in removeKeys) extendedData.Remove(key); } diff --git a/src/Exceptionless.Core/Extensions/ErrorExtensions.cs b/src/Exceptionless.Core/Extensions/ErrorExtensions.cs index 714e0c86bd..634f1fa8fc 100644 --- a/src/Exceptionless.Core/Extensions/ErrorExtensions.cs +++ b/src/Exceptionless.Core/Extensions/ErrorExtensions.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; @@ -58,9 +59,9 @@ public static StackingTarget GetStackingTarget(this Error error) }; } - public static StackingTarget? GetStackingTarget(this Event ev) + public static StackingTarget? GetStackingTarget(this Event ev, JsonSerializerOptions options) { - var error = ev.GetError(); + var error = ev.GetError(options); return error?.GetStackingTarget(); } diff --git a/src/Exceptionless.Core/Extensions/EventExtensions.cs b/src/Exceptionless.Core/Extensions/EventExtensions.cs index e9aa30e86d..0797660f87 100644 --- a/src/Exceptionless.Core/Extensions/EventExtensions.cs +++ b/src/Exceptionless.Core/Extensions/EventExtensions.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; @@ -13,63 +14,75 @@ public static bool HasError(this Event ev) return ev.Data is not null && ev.Data.ContainsKey(Event.KnownDataKeys.Error); } - public static Error? GetError(this Event ev) + public static Error? GetError(this Event ev, JsonSerializerOptions options) { if (!ev.HasError()) return null; try { - return ev.Data!.GetValue(Event.KnownDataKeys.Error); + return ev.Data!.GetValue(Event.KnownDataKeys.Error, options); + } + catch (Exception) + { + // Ignored } - catch (Exception) { } return null; } + public static bool HasSimpleError(this Event ev) { return ev.Data is not null && ev.Data.ContainsKey(Event.KnownDataKeys.SimpleError); } - - public static SimpleError? GetSimpleError(this Event ev) + public static SimpleError? GetSimpleError(this Event ev, JsonSerializerOptions options) { if (!ev.HasSimpleError()) return null; try { - return ev.Data!.GetValue(Event.KnownDataKeys.SimpleError); + return ev.Data!.GetValue(Event.KnownDataKeys.SimpleError, options); + } + catch (Exception) + { + // Ignored } - catch (Exception) { } return null; } - public static RequestInfo? GetRequestInfo(this Event ev) + public static RequestInfo? GetRequestInfo(this Event ev, JsonSerializerOptions options) { if (ev.Data is null || !ev.Data.ContainsKey(Event.KnownDataKeys.RequestInfo)) return null; try { - return ev.Data.GetValue(Event.KnownDataKeys.RequestInfo); + return ev.Data.GetValue(Event.KnownDataKeys.RequestInfo, options); + } + catch (Exception) + { + // Ignored } - catch (Exception) { } return null; } - public static EnvironmentInfo? GetEnvironmentInfo(this Event ev) + public static EnvironmentInfo? GetEnvironmentInfo(this Event ev, JsonSerializerOptions options) { if (ev.Data is null || !ev.Data.ContainsKey(Event.KnownDataKeys.EnvironmentInfo)) return null; try { - return ev.Data.GetValue(Event.KnownDataKeys.EnvironmentInfo); + return ev.Data.GetValue(Event.KnownDataKeys.EnvironmentInfo, options); + } + catch (Exception) + { + // Ignored } - catch (Exception) { } return null; } @@ -170,9 +183,21 @@ public static void AddRequestInfo(this Event ev, RequestInfo request) /// /// Gets the user info object from extended data. /// - public static UserInfo? GetUserIdentity(this Event ev) + public static UserInfo? GetUserIdentity(this Event ev, JsonSerializerOptions options) { - return ev.Data != null && ev.Data.TryGetValue(Event.KnownDataKeys.UserInfo, out object? value) ? value as UserInfo : null; + if (ev.Data is null || !ev.Data.ContainsKey(Event.KnownDataKeys.UserInfo)) + return null; + + try + { + return ev.Data.GetValue(Event.KnownDataKeys.UserInfo, options); + } + catch (Exception) + { + // Ignored + } + + return null; } public static string? GetVersion(this Event ev) @@ -194,9 +219,21 @@ public static void SetVersion(this Event ev, string? version) ev.Data[Event.KnownDataKeys.Version] = version.Trim(); } - public static SubmissionClient? GetSubmissionClient(this Event ev) + public static SubmissionClient? GetSubmissionClient(this Event ev, JsonSerializerOptions options) { - return ev.Data != null && ev.Data.TryGetValue(Event.KnownDataKeys.SubmissionClient, out object? value) ? value as SubmissionClient : null; + if (ev.Data is null || !ev.Data.ContainsKey(Event.KnownDataKeys.SubmissionClient)) + return null; + + try + { + return ev.Data.GetValue(Event.KnownDataKeys.SubmissionClient, options); + } + catch (Exception) + { + // Ignored + } + + return null; } public static bool HasLocation(this Event ev) @@ -204,9 +241,21 @@ public static bool HasLocation(this Event ev) return ev.Data != null && ev.Data.ContainsKey(Event.KnownDataKeys.Location); } - public static Location? GetLocation(this Event ev) + public static Location? GetLocation(this Event ev, JsonSerializerOptions options) { - return ev.Data != null && ev.Data.TryGetValue(Event.KnownDataKeys.Location, out object? value) ? value as Location : null; + if (ev.Data is null || !ev.Data.ContainsKey(Event.KnownDataKeys.Location)) + return null; + + try + { + return ev.Data.GetValue(Event.KnownDataKeys.Location, options); + } + catch (Exception) + { + // Ignored + } + + return null; } public static string? GetLevel(this Event ev) @@ -252,9 +301,21 @@ public static void SetEnvironmentInfo(this Event ev, EnvironmentInfo? environmen /// /// Gets the stacking info from extended data. /// - public static ManualStackingInfo? GetManualStackingInfo(this Event ev) + public static ManualStackingInfo? GetManualStackingInfo(this Event ev, JsonSerializerOptions options) { - return ev.Data != null && ev.Data.TryGetValue(Event.KnownDataKeys.ManualStackingInfo, out object? value) ? value as ManualStackingInfo : null; + if (ev.Data is null || !ev.Data.ContainsKey(Event.KnownDataKeys.ManualStackingInfo)) + return null; + + try + { + return ev.Data.GetValue(Event.KnownDataKeys.ManualStackingInfo, options); + } + catch (Exception) + { + // Ignored + } + + return null; } /// @@ -362,9 +423,21 @@ public static void RemoveUserIdentity(this Event ev) /// /// Gets the user description from extended data. /// - public static UserDescription? GetUserDescription(this Event ev) + public static UserDescription? GetUserDescription(this Event ev, JsonSerializerOptions options) { - return ev.Data != null && ev.Data.TryGetValue(Event.KnownDataKeys.UserDescription, out object? value) ? value as UserDescription : null; + if (ev.Data is null || !ev.Data.ContainsKey(Event.KnownDataKeys.UserDescription)) + return null; + + try + { + return ev.Data.GetValue(Event.KnownDataKeys.UserDescription, options); + } + catch (Exception) + { + // Ignored + } + + return null; } /// diff --git a/src/Exceptionless.Core/Extensions/JsonExtensions.cs b/src/Exceptionless.Core/Extensions/JsonExtensions.cs index b7d4b18f17..7c3e95e905 100644 --- a/src/Exceptionless.Core/Extensions/JsonExtensions.cs +++ b/src/Exceptionless.Core/Extensions/JsonExtensions.cs @@ -252,21 +252,23 @@ public static bool IsValueEmptyCollection(this JsonProperty property, object tar public static void AddModelConverters(this JsonSerializerSettings settings, ILogger logger) { - var knownEventDataTypes = new Dictionary { - { Event.KnownDataKeys.Error, typeof(Error) }, - { Event.KnownDataKeys.EnvironmentInfo, typeof(EnvironmentInfo) }, - { Event.KnownDataKeys.Location, typeof(Location) }, - { Event.KnownDataKeys.RequestInfo, typeof(RequestInfo) }, - { Event.KnownDataKeys.SimpleError, typeof(SimpleError) }, - { Event.KnownDataKeys.SubmissionClient, typeof(SubmissionClient) }, - { Event.KnownDataKeys.ManualStackingInfo, typeof(ManualStackingInfo) }, - { Event.KnownDataKeys.UserDescription, typeof(UserDescription) }, - { Event.KnownDataKeys.UserInfo, typeof(UserInfo) } - }; - - var knownProjectDataTypes = new Dictionary { - { Project.KnownDataKeys.SlackToken, typeof(SlackToken) } - }; + var knownEventDataTypes = new Dictionary + { + { Event.KnownDataKeys.Error, typeof(Error) }, + { Event.KnownDataKeys.EnvironmentInfo, typeof(EnvironmentInfo) }, + { Event.KnownDataKeys.Location, typeof(Location) }, + { Event.KnownDataKeys.RequestInfo, typeof(RequestInfo) }, + { Event.KnownDataKeys.SimpleError, typeof(SimpleError) }, + { Event.KnownDataKeys.SubmissionClient, typeof(SubmissionClient) }, + { Event.KnownDataKeys.ManualStackingInfo, typeof(ManualStackingInfo) }, + { Event.KnownDataKeys.UserDescription, typeof(UserDescription) }, + { Event.KnownDataKeys.UserInfo, typeof(UserInfo) } + }; + + var knownProjectDataTypes = new Dictionary + { + { Project.KnownDataKeys.SlackToken, typeof(SlackToken) } + }; settings.Converters.Add(new DataObjectConverter(logger)); settings.Converters.Add(new DataObjectConverter(logger, knownProjectDataTypes)); diff --git a/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs b/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs index 5c54406270..f502a0d7cc 100644 --- a/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs +++ b/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; @@ -175,7 +176,7 @@ public static bool UpdateSessionStart(this PersistentEvent ev, DateTime lastActi return true; } - public static PersistentEvent ToSessionStartEvent(this PersistentEvent source, DateTime? lastActivityUtc = null, bool? isSessionEnd = null, bool hasPremiumFeatures = true, bool includePrivateInformation = true) + public static PersistentEvent ToSessionStartEvent(this PersistentEvent source, JsonSerializerOptions jsonOptions, DateTime? lastActivityUtc = null, bool? isSessionEnd = null, bool hasPremiumFeatures = true, bool includePrivateInformation = true) { var startEvent = new PersistentEvent { @@ -191,11 +192,11 @@ public static PersistentEvent ToSessionStartEvent(this PersistentEvent source, D if (sessionId is not null) startEvent.SetSessionId(sessionId); if (includePrivateInformation) - startEvent.SetUserIdentity(source.GetUserIdentity()); - startEvent.SetLocation(source.GetLocation()); + startEvent.SetUserIdentity(source.GetUserIdentity(jsonOptions)); + startEvent.SetLocation(source.GetLocation(jsonOptions)); startEvent.SetVersion(source.GetVersion()); - var ei = source.GetEnvironmentInfo(); + var ei = source.GetEnvironmentInfo(jsonOptions); if (ei is not null) { startEvent.SetEnvironmentInfo(new EnvironmentInfo @@ -216,7 +217,7 @@ public static PersistentEvent ToSessionStartEvent(this PersistentEvent source, D }); } - var ri = source.GetRequestInfo(); + var ri = source.GetRequestInfo(jsonOptions); if (ri is not null) { startEvent.AddRequestInfo(new RequestInfo @@ -242,19 +243,19 @@ public static PersistentEvent ToSessionStartEvent(this PersistentEvent source, D return startEvent; } - public static IEnumerable GetIpAddresses(this PersistentEvent ev) + public static IEnumerable GetIpAddresses(this PersistentEvent ev, JsonSerializerOptions jsonOptions) { if (!String.IsNullOrEmpty(ev.Geo) && (ev.Geo.Contains('.') || ev.Geo.Contains(':'))) yield return ev.Geo.Trim(); - var ri = ev.GetRequestInfo(); + var ri = ev.GetRequestInfo(jsonOptions); if (!String.IsNullOrEmpty(ri?.ClientIpAddress)) { foreach (string ip in ri.ClientIpAddress.Split(_commaSeparator, StringSplitOptions.RemoveEmptyEntries)) yield return ip.Trim(); } - var ei = ev.GetEnvironmentInfo(); + var ei = ev.GetEnvironmentInfo(jsonOptions); if (!String.IsNullOrEmpty(ei?.IpAddress)) { foreach (string ip in ei.IpAddress.Split(_commaSeparator, StringSplitOptions.RemoveEmptyEntries)) diff --git a/src/Exceptionless.Core/Extensions/TypeExtensions.cs b/src/Exceptionless.Core/Extensions/TypeExtensions.cs index 003c47a7da..c37a86314b 100644 --- a/src/Exceptionless.Core/Extensions/TypeExtensions.cs +++ b/src/Exceptionless.Core/Extensions/TypeExtensions.cs @@ -41,7 +41,6 @@ public static T ToType(this object value) ArgumentNullException.ThrowIfNull(value); var targetType = typeof(T); - var converter = TypeDescriptor.GetConverter(targetType); var valueType = value.GetType(); if (targetType.IsAssignableFrom(valueType)) @@ -63,7 +62,8 @@ public static T ToType(this object value) if (valueType.IsNumeric() && targetType.IsEnum) return (T)Enum.ToObject(targetType, value); - if (converter is not null && converter.CanConvertFrom(valueType)) + var converter = TypeDescriptor.GetConverter(targetType); + if (converter.CanConvertFrom(valueType)) { object? convertedValue = converter.ConvertFrom(value); return (convertedValue is T convertedValue1 ? convertedValue1 : default) diff --git a/src/Exceptionless.Core/Jobs/CloseInactiveSessionsJob.cs b/src/Exceptionless.Core/Jobs/CloseInactiveSessionsJob.cs index e042d56436..fade8ec24e 100644 --- a/src/Exceptionless.Core/Jobs/CloseInactiveSessionsJob.cs +++ b/src/Exceptionless.Core/Jobs/CloseInactiveSessionsJob.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; @@ -19,9 +20,11 @@ public class CloseInactiveSessionsJob : JobWithLockBase, IHealthCheck private readonly IEventRepository _eventRepository; private readonly ICacheClient _cache; private readonly ILockProvider _lockProvider; + private readonly JsonSerializerOptions _jsonOptions; private DateTime? _lastActivity; public CloseInactiveSessionsJob(IEventRepository eventRepository, ICacheClient cacheClient, + JsonSerializerOptions jsonOptions, TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory @@ -30,6 +33,7 @@ ILoggerFactory loggerFactory _eventRepository = eventRepository; _cache = cacheClient; _lockProvider = new ThrottlingLockProvider(cacheClient, 1, TimeSpan.FromMinutes(1), timeProvider, resiliencePolicyProvider, loggerFactory); + _jsonOptions = jsonOptions; } protected override Task GetLockAsync(CancellationToken cancellationToken = default) @@ -118,7 +122,7 @@ protected override async Task RunInternalAsync(JobContext context) return result; } - var user = sessionStart.GetUserIdentity(); + var user = sessionStart.GetUserIdentity(_jsonOptions); if (String.IsNullOrWhiteSpace(user?.Identity)) return null; diff --git a/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs b/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs index 32f308f3cb..e51f0f0a27 100644 --- a/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs +++ b/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; using Exceptionless.Core.Mail; @@ -28,6 +29,7 @@ public class EventNotificationsJob : QueueJobBase private readonly IEventRepository _eventRepository; private readonly ICacheClient _cache; private readonly UserAgentParser _parser; + private readonly JsonSerializerOptions _jsonOptions; public EventNotificationsJob(IQueue queue, SlackService slackService, @@ -39,6 +41,7 @@ public EventNotificationsJob(IQueue queue, IEventRepository eventRepository, ICacheClient cacheClient, UserAgentParser parser, + JsonSerializerOptions jsonOptions, TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory) : base(queue, timeProvider, resiliencePolicyProvider, loggerFactory) @@ -52,6 +55,7 @@ public EventNotificationsJob(IQueue queue, _eventRepository = eventRepository; _cache = cacheClient; _parser = parser; + _jsonOptions = jsonOptions; } protected override async Task ProcessQueueEntryAsync(QueueEntryContext context) @@ -112,7 +116,7 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex _logger.LogTrace("Settings: new error={ReportNewErrors} critical error={ReportCriticalErrors} regression={ReportEventRegressions} new={ReportNewEvents} critical={ReportCriticalEvents}", settings.ReportNewErrors, settings.ReportCriticalErrors, settings.ReportEventRegressions, settings.ReportNewEvents, settings.ReportCriticalEvents); _logger.LogTrace("Should process: new error={ShouldReportNewError} critical error={ShouldReportCriticalError} regression={ShouldReportRegression} new={ShouldReportNewEvent} critical={ShouldReportCriticalEvent}", shouldReportNewError, shouldReportCriticalError, shouldReportRegression, shouldReportNewEvent, shouldReportCriticalEvent); } - var request = ev.GetRequestInfo(); + var request = ev.GetRequestInfo(_jsonOptions); // check for known bots if the user has elected to not report them if (shouldReport && !String.IsNullOrEmpty(request?.UserAgent)) { diff --git a/src/Exceptionless.Core/Mail/Mailer.cs b/src/Exceptionless.Core/Mail/Mailer.cs index 8840cfb6b2..f18e70bfcc 100644 --- a/src/Exceptionless.Core/Mail/Mailer.cs +++ b/src/Exceptionless.Core/Mail/Mailer.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Plugins.Formatting; @@ -17,14 +18,16 @@ public class Mailer : IMailer private readonly FormattingPluginManager _pluginManager; private readonly AppOptions _appOptions; private readonly TimeProvider _timeProvider; + private readonly JsonSerializerOptions _jsonOptions; private readonly ILogger _logger; - public Mailer(IQueue queue, FormattingPluginManager pluginManager, AppOptions appOptions, TimeProvider timeProvider, ILogger logger) + public Mailer(IQueue queue, FormattingPluginManager pluginManager, JsonSerializerOptions jsonOptions, AppOptions appOptions, TimeProvider timeProvider, ILogger logger) { _queue = queue; _pluginManager = pluginManager; _appOptions = appOptions; _timeProvider = timeProvider; + _jsonOptions = jsonOptions; _logger = logger; } @@ -56,7 +59,7 @@ public async Task SendEventNoticeAsync(User user, PersistentEvent ev, Proj }; AddDefaultFields(ev, result.Data); - AddUserInfo(ev, messageData); + AddUserInfo(ev, messageData, _jsonOptions); const string template = "event-notice"; await QueueMessageAsync(new MailMessage @@ -68,10 +71,10 @@ await QueueMessageAsync(new MailMessage return true; } - private static void AddUserInfo(PersistentEvent ev, Dictionary data) + private static void AddUserInfo(PersistentEvent ev, Dictionary data, JsonSerializerOptions jsonOptions) { - var ud = ev.GetUserDescription(); - var ui = ev.GetUserIdentity(); + var ud = ev.GetUserDescription(jsonOptions); + var ui = ev.GetUserIdentity(jsonOptions); if (!String.IsNullOrEmpty(ud?.Description)) data["UserDescription"] = ud.Description; diff --git a/src/Exceptionless.Core/Models/SlackToken.cs b/src/Exceptionless.Core/Models/SlackToken.cs index b2fe4b6acf..28d89e3a72 100644 --- a/src/Exceptionless.Core/Models/SlackToken.cs +++ b/src/Exceptionless.Core/Models/SlackToken.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json; +using System.Text.Json; +using Newtonsoft.Json; namespace Exceptionless.Core.Models; @@ -34,12 +35,12 @@ public SlackMessage(string text) public class SlackAttachment { - public SlackAttachment(PersistentEvent ev) + public SlackAttachment(PersistentEvent ev, JsonSerializerOptions jsonOptions) { TimeStamp = ev.Date.ToUnixTimeSeconds(); - var ud = ev.GetUserDescription(); - var ui = ev.GetUserIdentity(); + var ud = ev.GetUserDescription(jsonOptions); + var ui = ev.GetUserIdentity(jsonOptions); Text = ud?.Description; string? displayName = null; diff --git a/src/Exceptionless.Core/Models/Stack.cs b/src/Exceptionless.Core/Models/Stack.cs index 364e9c269f..f387a2f1b2 100644 --- a/src/Exceptionless.Core/Models/Stack.cs +++ b/src/Exceptionless.Core/Models/Stack.cs @@ -1,8 +1,8 @@ using System.Collections.ObjectModel; using System.Diagnostics; using System.Runtime.Serialization; +using System.Text.Json.Serialization; using Foundatio.Repositories.Models; -using Newtonsoft.Json; using Newtonsoft.Json.Converters; namespace Exceptionless.Core.Models; @@ -123,13 +123,26 @@ public static class KnownTypes } } -[JsonConverter(typeof(StringEnumConverter))] +[JsonConverter(typeof(JsonStringEnumConverter))] +[Newtonsoft.Json.JsonConverter(typeof(StringEnumConverter))] public enum StackStatus { - [EnumMember(Value = "open")] Open, - [EnumMember(Value = "fixed")] Fixed, - [EnumMember(Value = "regressed")] Regressed, - [EnumMember(Value = "snoozed")] Snoozed, - [EnumMember(Value = "ignored")] Ignored, - [EnumMember(Value = "discarded")] Discarded + [JsonStringEnumMemberName("open")] + [EnumMember(Value = "open")] + Open, + [JsonStringEnumMemberName("fixed")] + [EnumMember(Value = "fixed")] + Fixed, + [JsonStringEnumMemberName("regressed")] + [EnumMember(Value = "regressed")] + Regressed, + [JsonStringEnumMemberName("snoozed")] + [EnumMember(Value = "snoozed")] + Snoozed, + [JsonStringEnumMemberName("ignored")] + [EnumMember(Value = "ignored")] + Ignored, + [JsonStringEnumMemberName("discarded")] + [EnumMember(Value = "discarded")] + Discarded } diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/03_ManualStackingPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/03_ManualStackingPlugin.cs index 873b3ed5c8..4b17fd8dc3 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/03_ManualStackingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/03_ManualStackingPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -7,11 +8,16 @@ namespace Exceptionless.Core.Plugins.EventProcessor; [Priority(3)] public sealed class ManualStackingPlugin : EventProcessorPluginBase { - public ManualStackingPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + private readonly JsonSerializerOptions _jsonOptions; + + public ManualStackingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + { + _jsonOptions = jsonOptions; + } public override Task EventProcessingAsync(EventContext context) { - var msi = context.Event.GetManualStackingInfo(); + var msi = context.Event.GetManualStackingInfo(_jsonOptions); if (msi?.SignatureData is not null) { foreach (var kvp in msi.SignatureData) diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/0_ThrottleBotsPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/0_ThrottleBotsPlugin.cs index f3396215a7..36610ef3a8 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/0_ThrottleBotsPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/0_ThrottleBotsPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models.WorkItems; using Exceptionless.Core.Pipeline; using Exceptionless.DateTimeExtensions; @@ -15,13 +16,15 @@ public sealed class ThrottleBotsPlugin : EventProcessorPluginBase private readonly ICacheClient _cache; private readonly IQueue _workItemQueue; private readonly TimeProvider _timeProvider; + private readonly JsonSerializerOptions _jsonOptions; private readonly TimeSpan _throttlingPeriod = TimeSpan.FromMinutes(5); public ThrottleBotsPlugin(ICacheClient cacheClient, IQueue workItemQueue, - TimeProvider timeProvider, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + JsonSerializerOptions jsonOptions, TimeProvider timeProvider, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { _cache = cacheClient; _workItemQueue = workItemQueue; + _jsonOptions = jsonOptions; _timeProvider = timeProvider; } @@ -35,7 +38,7 @@ public override async Task EventBatchProcessingAsync(ICollection c return; // Throttle errors by client ip address to no more than X every 5 minutes. - var clientIpAddressGroups = contexts.GroupBy(c => c.Event.GetRequestInfo()?.ClientIpAddress); + var clientIpAddressGroups = contexts.GroupBy(c => c.Event.GetRequestInfo(_jsonOptions)?.ClientIpAddress); foreach (var clientIpAddressGroup in clientIpAddressGroups) { if (String.IsNullOrEmpty(clientIpAddressGroup.Key) || clientIpAddressGroup.Key.IsPrivateNetwork()) diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/10_NotFoundPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/10_NotFoundPlugin.cs index a18fbca82d..99599dcaa1 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/10_NotFoundPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/10_NotFoundPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -8,7 +9,12 @@ namespace Exceptionless.Core.Plugins.EventProcessor; [Priority(10)] public sealed class NotFoundPlugin : EventProcessorPluginBase { - public NotFoundPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + private readonly JsonSerializerOptions _jsonOptions; + + public NotFoundPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + { + _jsonOptions = jsonOptions; + } public override Task EventProcessingAsync(EventContext context) { @@ -18,7 +24,7 @@ public override Task EventProcessingAsync(EventContext context) context.Event.Data.Remove(Event.KnownDataKeys.EnvironmentInfo); context.Event.Data.Remove(Event.KnownDataKeys.TraceLog); - var req = context.Event.GetRequestInfo(); + var req = context.Event.GetRequestInfo(_jsonOptions); if (req is null) return Task.CompletedTask; diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/20_ErrorPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/20_ErrorPlugin.cs index 957c9186cc..f94a57e519 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/20_ErrorPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/20_ErrorPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Exceptionless.Core.Utility; @@ -9,14 +10,19 @@ namespace Exceptionless.Core.Plugins.EventProcessor; [Priority(20)] public sealed class ErrorPlugin : EventProcessorPluginBase { - public ErrorPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + private readonly JsonSerializerOptions _jsonOptions; + + public ErrorPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + { + _jsonOptions = jsonOptions; + } public override Task EventProcessingAsync(EventContext context) { if (!context.Event.IsError()) return Task.CompletedTask; - var error = context.Event.GetError(); + var error = context.Event.GetError(_jsonOptions); if (error is null) return Task.CompletedTask; @@ -34,7 +40,7 @@ public override Task EventProcessingAsync(EventContext context) if (context.HasProperty("UserNamespaces")) userNamespaces = context.GetProperty("UserNamespaces")?.SplitAndTrim([',']); - var signature = new ErrorSignature(error, userNamespaces, userCommonMethods); + var signature = new ErrorSignature(error, _jsonOptions, userNamespaces, userCommonMethods); if (signature.SignatureInfo.Count <= 0) return Task.CompletedTask; diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/30_SimpleErrorPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/30_SimpleErrorPlugin.cs index 3207fc405b..5d3da08178 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/30_SimpleErrorPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/30_SimpleErrorPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -8,14 +9,19 @@ namespace Exceptionless.Core.Plugins.EventProcessor; [Priority(30)] public sealed class SimpleErrorPlugin : EventProcessorPluginBase { - public SimpleErrorPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + private readonly JsonSerializerOptions _jsonOptions; + + public SimpleErrorPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + { + _jsonOptions = jsonOptions; + } public override Task EventProcessingAsync(EventContext context) { if (!context.Event.IsError()) return Task.CompletedTask; - var error = context.Event.GetSimpleError(); + var error = context.Event.GetSimpleError(_jsonOptions); if (error is null) return Task.CompletedTask; diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/40_RequestInfoPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/40_RequestInfoPlugin.cs index 612114df7e..56d66b938f 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/40_RequestInfoPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/40_RequestInfoPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Pipeline; @@ -24,10 +25,12 @@ public sealed class RequestInfoPlugin : EventProcessorPluginBase ]; private readonly UserAgentParser _parser; + private readonly JsonSerializerOptions _jsonOptions; - public RequestInfoPlugin(UserAgentParser parser, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public RequestInfoPlugin(UserAgentParser parser, JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { _parser = parser; + _jsonOptions = jsonOptions; } public override async Task EventBatchProcessingAsync(ICollection contexts) @@ -36,13 +39,13 @@ public override async Task EventBatchProcessingAsync(ICollection c var exclusions = DefaultExclusions.Union(project.Configuration.Settings.GetStringCollection(SettingsDictionary.KnownKeys.DataExclusions)).ToList(); foreach (var context in contexts) { - var request = context.Event.GetRequestInfo(); + var request = context.Event.GetRequestInfo(_jsonOptions); if (request is null) continue; if (context.IncludePrivateInformation) { - var submissionClient = context.Event.GetSubmissionClient(); + var submissionClient = context.Event.GetSubmissionClient(_jsonOptions); AddClientIpAddress(request, submissionClient); } else diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/45_EnvironmentInfoPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/45_EnvironmentInfoPlugin.cs index b34a216813..e3fdcd23b6 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/45_EnvironmentInfoPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/45_EnvironmentInfoPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -8,17 +9,22 @@ namespace Exceptionless.Core.Plugins.EventProcessor.Default; [Priority(45)] public sealed class EnvironmentInfoPlugin : EventProcessorPluginBase { - public EnvironmentInfoPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + private readonly JsonSerializerOptions _jsonOptions; + + public EnvironmentInfoPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + { + _jsonOptions = jsonOptions; + } public override Task EventProcessingAsync(EventContext context) { - var environment = context.Event.GetEnvironmentInfo(); + var environment = context.Event.GetEnvironmentInfo(_jsonOptions); if (environment is null) return Task.CompletedTask; if (context.IncludePrivateInformation) { - var submissionClient = context.Event.GetSubmissionClient(); + var submissionClient = context.Event.GetSubmissionClient(_jsonOptions); AddClientIpAddress(environment, submissionClient); } else diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/50_GeoPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/50_GeoPlugin.cs index ff701ea29d..13bea073bf 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/50_GeoPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/50_GeoPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Geo; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; @@ -10,10 +11,12 @@ namespace Exceptionless.Core.Plugins.EventProcessor.Default; public sealed class GeoPlugin : EventProcessorPluginBase { private readonly IGeoIpService _geoIpService; + private readonly JsonSerializerOptions _jsonOptions; - public GeoPlugin(IGeoIpService geoIpService, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public GeoPlugin(IGeoIpService geoIpService, JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { _geoIpService = geoIpService; + _jsonOptions = jsonOptions; } public override Task EventBatchProcessingAsync(ICollection contexts) @@ -32,7 +35,7 @@ public override Task EventBatchProcessingAsync(ICollection context // The geo coordinates are all the same, set the location from the result of any of the ip addresses. if (!String.IsNullOrEmpty(group.Key)) { - var ips = group.SelectMany(c => c.Event.GetIpAddresses()).Union(new[] { group.First().EventPostInfo?.IpAddress }).Distinct().ToList(); + var ips = group.SelectMany(c => c.Event.GetIpAddresses(_jsonOptions)).Union(new[] { group.First().EventPostInfo?.IpAddress }).Distinct().ToList(); if (ips.Count > 0) tasks.Add(UpdateGeoInformationAsync(group, ips)); continue; @@ -41,7 +44,7 @@ public override Task EventBatchProcessingAsync(ICollection context // Each event in the group could be a different user; foreach (var context in group) { - var ips = context.Event.GetIpAddresses().Union(new[] { context.EventPostInfo?.IpAddress }).ToList(); + var ips = context.Event.GetIpAddresses(_jsonOptions).Union(new[] { context.EventPostInfo?.IpAddress }).ToList(); if (ips.Count > 0) tasks.Add(UpdateGeoInformationAsync(context, ips)); } diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/70_SessionPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/70_SessionPlugin.cs index 81e6927da1..42b0169be7 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/70_SessionPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/70_SessionPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Exceptionless.Core.Repositories; @@ -17,19 +18,21 @@ public sealed class SessionPlugin : EventProcessorPluginBase private readonly UpdateStatsAction _updateStats; private readonly AssignToStackAction _assignToStack; private readonly LocationPlugin _locationPlugin; + private readonly JsonSerializerOptions _jsonOptions; - public SessionPlugin(ICacheClient cacheClient, IEventRepository eventRepository, AssignToStackAction assignToStack, UpdateStatsAction updateStats, LocationPlugin locationPlugin, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public SessionPlugin(ICacheClient cacheClient, IEventRepository eventRepository, AssignToStackAction assignToStack, UpdateStatsAction updateStats, LocationPlugin locationPlugin, JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { _cache = new ScopedCacheClient(cacheClient, "session"); _eventRepository = eventRepository; _assignToStack = assignToStack; _updateStats = updateStats; _locationPlugin = locationPlugin; + _jsonOptions = jsonOptions; } public override Task EventBatchProcessingAsync(ICollection contexts) { - var autoSessionEvents = contexts.Where(c => !String.IsNullOrWhiteSpace(c.Event.GetUserIdentity()?.Identity) && String.IsNullOrEmpty(c.Event.GetSessionId())).ToList(); + var autoSessionEvents = contexts.Where(c => !String.IsNullOrWhiteSpace(c.Event.GetUserIdentity(_jsonOptions)?.Identity) && String.IsNullOrEmpty(c.Event.GetSessionId())).ToList(); var manualSessionsEvents = contexts.Where(c => !String.IsNullOrEmpty(c.Event.GetSessionId())).ToList(); return Task.WhenAll( @@ -122,7 +125,7 @@ private async Task ProcessAutoSessionsAsync(ICollection contexts) { var identityGroups = contexts .OrderBy(c => c.Event.Date) - .GroupBy(c => c.Event.GetUserIdentity()?.Identity); + .GroupBy(c => c.Event.GetUserIdentity(_jsonOptions)?.Identity); foreach (var identityGroup in identityGroups) { @@ -283,7 +286,7 @@ private Task SetIdentitySessionIdAsync(string projectId, string identity, private async Task CreateSessionStartEventAsync(EventContext startContext, DateTime? lastActivityUtc, bool? isSessionEnd) { - var startEvent = startContext.Event.ToSessionStartEvent(lastActivityUtc, isSessionEnd, startContext.Organization.HasPremiumFeatures, startContext.IncludePrivateInformation); + var startEvent = startContext.Event.ToSessionStartEvent(_jsonOptions, lastActivityUtc, isSessionEnd, startContext.Organization.HasPremiumFeatures, startContext.IncludePrivateInformation); var startEventContexts = new List { new(startEvent, startContext.Organization, startContext.Project) }; diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/80_AngularPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/80_AngularPlugin.cs index 6353f9d354..5d89be33e9 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/80_AngularPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/80_AngularPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -8,14 +9,19 @@ namespace Exceptionless.Core.Plugins.EventProcessor; [Priority(80)] public sealed class AngularPlugin : EventProcessorPluginBase { - public AngularPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + private readonly JsonSerializerOptions _jsonOptions; + + public AngularPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + { + _jsonOptions = jsonOptions; + } public override Task EventProcessingAsync(EventContext context) { if (!context.Event.IsError()) return Task.CompletedTask; - var error = context.Event.GetError(); + var error = context.Event.GetError(_jsonOptions); if (error is null) return Task.CompletedTask; diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/90_RemovePrivateInformationPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/90_RemovePrivateInformationPlugin.cs index 12a5f28efd..757ee3cfdd 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/90_RemovePrivateInformationPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/90_RemovePrivateInformationPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Pipeline; +using System.Text.Json; +using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.EventProcessor.Default; @@ -6,7 +7,12 @@ namespace Exceptionless.Core.Plugins.EventProcessor.Default; [Priority(90)] public sealed class RemovePrivateInformationPlugin : EventProcessorPluginBase { - public RemovePrivateInformationPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + private readonly JsonSerializerOptions _jsonOptions; + + public RemovePrivateInformationPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + { + _jsonOptions = jsonOptions; + } public override Task EventProcessingAsync(EventContext context) { @@ -15,7 +21,7 @@ public override Task EventProcessingAsync(EventContext context) context.Event.RemoveUserIdentity(); - var description = context.Event.GetUserDescription(); + var description = context.Event.GetUserDescription(_jsonOptions); if (description is not null) { description.EmailAddress = null; diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/05_ManualStackingFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/05_ManualStackingFormattingPlugin.cs index fa9b1eb2e7..3f3d70dbf5 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/05_ManualStackingFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/05_ManualStackingFormattingPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Models; +using System.Text.Json; +using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -7,11 +8,11 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(5)] public sealed class ManualStackingFormattingPlugin : FormattingPluginBase { - public ManualStackingFormattingPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + public ManualStackingFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } public override string? GetStackTitle(PersistentEvent ev) { - var msi = ev.GetManualStackingInfo(); + var msi = ev.GetManualStackingInfo(_jsonOptions); return !String.IsNullOrWhiteSpace(msi?.Title) ? msi.Title : null; } } diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs index 763258235f..8b79ac1c28 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -8,7 +9,7 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(10)] public sealed class SimpleErrorFormattingPlugin : FormattingPluginBase { - public SimpleErrorFormattingPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + public SimpleErrorFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } private bool ShouldHandle(PersistentEvent ev) { @@ -38,7 +39,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var error = ev.GetSimpleError(); + var error = ev.GetSimpleError(_jsonOptions); return error?.Message; } @@ -47,12 +48,12 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var error = ev.GetSimpleError(); + var error = ev.GetSimpleError(_jsonOptions); if (error is null) return null; var data = new Dictionary { { "Message", ev.Message } }; - AddUserIdentitySummaryData(data, ev.GetUserIdentity()); + AddUserIdentitySummaryData(data, ev.GetUserIdentity(_jsonOptions)); if (!String.IsNullOrEmpty(error.Type)) { @@ -60,7 +61,7 @@ private bool ShouldHandle(PersistentEvent ev) data.Add("TypeFullName", error.Type); } - var requestInfo = ev.GetRequestInfo(); + var requestInfo = ev.GetRequestInfo(_jsonOptions); if (!String.IsNullOrEmpty(requestInfo?.Path)) data.Add("Path", requestInfo.Path); @@ -72,7 +73,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var error = ev.GetSimpleError(); + var error = ev.GetSimpleError(_jsonOptions); if (error is null) return null; @@ -95,7 +96,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!String.IsNullOrEmpty(errorTypeName)) data.Add("Type", errorTypeName); - var requestInfo = ev.GetRequestInfo(); + var requestInfo = ev.GetRequestInfo(_jsonOptions); if (requestInfo is not null) data.Add("Url", requestInfo.GetFullPath(true, true, true)); @@ -107,7 +108,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var error = ev.GetSimpleError(); + var error = ev.GetSimpleError(_jsonOptions); if (error is null) return null; @@ -125,7 +126,7 @@ private bool ShouldHandle(PersistentEvent ev) if (isCritical) notificationType = String.Concat("critical ", notificationType); - var attachment = new SlackMessage.SlackAttachment(ev) + var attachment = new SlackMessage.SlackAttachment(ev, _jsonOptions) { Color = "#BB423F", Fields = diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs index eab703c381..250bdf945c 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -8,7 +9,7 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(20)] public sealed class ErrorFormattingPlugin : FormattingPluginBase { - public ErrorFormattingPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + public ErrorFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } private bool ShouldHandle(PersistentEvent ev) { @@ -20,7 +21,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var error = ev.GetError(); + var error = ev.GetError(_jsonOptions); return error?.Message; } @@ -58,12 +59,12 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var stackingTarget = ev.GetStackingTarget(); + var stackingTarget = ev.GetStackingTarget(_jsonOptions); if (stackingTarget?.Error is null) return null; var data = new Dictionary { { "Message", ev.Message } }; - AddUserIdentitySummaryData(data, ev.GetUserIdentity()); + AddUserIdentitySummaryData(data, ev.GetUserIdentity(_jsonOptions)); if (!String.IsNullOrEmpty(stackingTarget.Error.Type)) { @@ -77,7 +78,7 @@ private bool ShouldHandle(PersistentEvent ev) data.Add("MethodFullName", stackingTarget.Method.GetFullName()); } - var requestInfo = ev.GetRequestInfo(); + var requestInfo = ev.GetRequestInfo(_jsonOptions); if (!String.IsNullOrEmpty(requestInfo?.Path)) data.Add("Path", requestInfo.Path); @@ -89,7 +90,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var error = ev.GetError(); + var error = ev.GetError(_jsonOptions); var stackingTarget = error?.GetStackingTarget(); if (stackingTarget?.Error is null) return null; @@ -116,7 +117,7 @@ private bool ShouldHandle(PersistentEvent ev) if (stackingTarget.Method?.Name is not null) data.Add("Method", stackingTarget.Method.Name.Truncate(60)); - var requestInfo = ev.GetRequestInfo(); + var requestInfo = ev.GetRequestInfo(_jsonOptions); if (requestInfo is not null) data.Add("Url", requestInfo.GetFullPath(true, true, true)); @@ -128,7 +129,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var error = ev.GetError(); + var error = ev.GetError(_jsonOptions); var stackingTarget = error?.GetStackingTarget(); if (stackingTarget?.Error is null) return null; @@ -147,7 +148,7 @@ private bool ShouldHandle(PersistentEvent ev) if (isCritical) notificationType = String.Concat("critical ", notificationType); - var attachment = new SlackMessage.SlackAttachment(ev) + var attachment = new SlackMessage.SlackAttachment(ev, _jsonOptions) { Color = "#BB423F", Fields = diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs index 46bd0108ef..c3f602bf24 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -8,7 +9,7 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(30)] public sealed class NotFoundFormattingPlugin : FormattingPluginBase { - public NotFoundFormattingPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + public NotFoundFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } private bool ShouldHandle(PersistentEvent ev) { @@ -37,9 +38,9 @@ private bool ShouldHandle(PersistentEvent ev) return null; var data = new Dictionary { { "Source", ev.Source } }; - AddUserIdentitySummaryData(data, ev.GetUserIdentity()); + AddUserIdentitySummaryData(data, ev.GetUserIdentity(_jsonOptions)); - var ips = ev.GetIpAddresses().ToList(); + var ips = ev.GetIpAddresses(_jsonOptions).ToList(); if (ips.Count > 0) data.Add("IpAddress", ips); @@ -61,7 +62,7 @@ private bool ShouldHandle(PersistentEvent ev) notificationType = String.Concat("Critical ", notificationType.ToLowerInvariant()); string subject = String.Concat(notificationType, ": ", ev.Source).Truncate(120); - var requestInfo = ev.GetRequestInfo(); + var requestInfo = ev.GetRequestInfo(_jsonOptions); var data = new Dictionary { { "Url", requestInfo?.GetFullPath(true, true, true) ?? ev.Source?.Truncate(60) } }; @@ -83,8 +84,8 @@ private bool ShouldHandle(PersistentEvent ev) if (isCritical) notificationType = String.Concat("critical ", notificationType); - var requestInfo = ev.GetRequestInfo(); - var attachment = new SlackMessage.SlackAttachment(ev) + var requestInfo = ev.GetRequestInfo(_jsonOptions); + var attachment = new SlackMessage.SlackAttachment(ev, _jsonOptions) { Color = "#BB423F", Fields = diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs index 704301748d..6b02b61052 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -8,7 +9,7 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(40)] public sealed class UsageFormattingPlugin : FormattingPluginBase { - public UsageFormattingPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + public UsageFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } private bool ShouldHandle(PersistentEvent ev) { @@ -37,7 +38,7 @@ private bool ShouldHandle(PersistentEvent ev) return null; var data = new Dictionary { { "Source", ev.Source } }; - AddUserIdentitySummaryData(data, ev.GetUserIdentity()); + AddUserIdentitySummaryData(data, ev.GetUserIdentity(_jsonOptions)); return new SummaryData { Id = ev.Id, TemplateKey = "event-feature-summary", Data = data }; } @@ -60,7 +61,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var attachment = new SlackMessage.SlackAttachment(ev) + var attachment = new SlackMessage.SlackAttachment(ev, _jsonOptions) { Fields = [ diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/50_SessionFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/50_SessionFormattingPlugin.cs index dedf16de15..1dc5710829 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/50_SessionFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/50_SessionFormattingPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -8,7 +9,7 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(50)] public sealed class SessionFormattingPlugin : FormattingPluginBase { - public SessionFormattingPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + public SessionFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } private bool ShouldHandle(PersistentEvent ev) { @@ -40,7 +41,7 @@ private bool ShouldHandle(PersistentEvent ev) return null; var data = new Dictionary { { "SessionId", ev.GetSessionId() }, { "Type", ev.Type } }; - AddUserIdentitySummaryData(data, ev.GetUserIdentity()); + AddUserIdentitySummaryData(data, ev.GetUserIdentity(_jsonOptions)); if (ev.IsSessionStart()) { diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs index 9fbae27310..3eca0c290c 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -8,7 +9,7 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(60)] public sealed class LogFormattingPlugin : FormattingPluginBase { - public LogFormattingPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + public LogFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } private bool ShouldHandle(PersistentEvent ev) { @@ -49,7 +50,7 @@ private bool ShouldHandle(PersistentEvent ev) return null; var data = new Dictionary { { "Message", ev.Message } }; - AddUserIdentitySummaryData(data, ev.GetUserIdentity()); + AddUserIdentitySummaryData(data, ev.GetUserIdentity(_jsonOptions)); if (!String.IsNullOrWhiteSpace(ev.Source)) { @@ -91,7 +92,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!String.IsNullOrEmpty(level)) data.Add("Level", level.Truncate(60)); - var requestInfo = ev.GetRequestInfo(); + var requestInfo = ev.GetRequestInfo(_jsonOptions); if (requestInfo is not null) data.Add("Url", requestInfo.GetFullPath(true, true, true)); @@ -113,7 +114,7 @@ private bool ShouldHandle(PersistentEvent ev) notificationType = String.Concat("critical ", notificationType); string source = !String.IsNullOrEmpty(ev.Source) ? ev.Source : "(Global)"; - var attachment = new SlackMessage.SlackAttachment(ev) + var attachment = new SlackMessage.SlackAttachment(ev, _jsonOptions) { Fields = [ @@ -148,7 +149,7 @@ private bool ShouldHandle(PersistentEvent ev) attachment.Fields.Add(new SlackMessage.SlackAttachmentFields { Title = "Level", Value = level.Truncate(60) }); } - var requestInfo = ev.GetRequestInfo(); + var requestInfo = ev.GetRequestInfo(_jsonOptions); if (requestInfo is not null) attachment.Fields.Add(new SlackMessage.SlackAttachmentFields { Title = "Url", Value = requestInfo.GetFullPath(true, true, true) }); diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs index 32cc72fde3..39ec6593e9 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -8,7 +9,7 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(99)] public sealed class DefaultFormattingPlugin : FormattingPluginBase { - public DefaultFormattingPlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + public DefaultFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } public override string GetStackTitle(PersistentEvent ev) { @@ -36,7 +37,7 @@ public override SummaryData GetEventSummaryData(PersistentEvent ev) { "Type", ev.Type } }; - AddUserIdentitySummaryData(data, ev.GetUserIdentity()); + AddUserIdentitySummaryData(data, ev.GetUserIdentity(_jsonOptions)); return new SummaryData { Id = ev.Id, TemplateKey = "event-summary", Data = data }; } @@ -67,7 +68,7 @@ public override MailMessageData GetEventNotificationMailMessageData(PersistentEv if (!String.IsNullOrEmpty(ev.Source)) data.Add("Source", ev.Source.Truncate(60)); - var requestInfo = ev.GetRequestInfo(); + var requestInfo = ev.GetRequestInfo(_jsonOptions); if (requestInfo is not null) data.Add("Url", requestInfo.GetFullPath(true, true, true)); @@ -89,7 +90,7 @@ public override SlackMessage GetSlackEventNotification(PersistentEvent ev, Proje if (isCritical) notificationType = String.Concat("Critical ", notificationType.ToLowerInvariant()); - var attachment = new SlackMessage.SlackAttachment(ev); + var attachment = new SlackMessage.SlackAttachment(ev, _jsonOptions); if (!String.IsNullOrEmpty(ev.Message)) attachment.Fields.Add(new SlackMessage.SlackAttachmentFields { Title = "Message", Value = ev.Message.Truncate(60) }); diff --git a/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs b/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs index 1c2e6e6f84..926a83eb95 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Microsoft.Extensions.Logging; @@ -7,7 +8,12 @@ namespace Exceptionless.Core.Plugins.Formatting; public abstract class FormattingPluginBase : PluginBase, IFormattingPlugin { - public FormattingPluginBase(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + protected readonly JsonSerializerOptions _jsonOptions; + + public FormattingPluginBase(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + { + _jsonOptions = jsonOptions; + } public virtual SummaryData? GetStackSummaryData(Stack stack) { @@ -36,7 +42,7 @@ public FormattingPluginBase(AppOptions options, ILoggerFactory loggerFactory) : protected void AddDefaultSlackFields(PersistentEvent ev, List attachmentFields, bool includeUrl = true) { - var requestInfo = ev.GetRequestInfo(); + var requestInfo = ev.GetRequestInfo(_jsonOptions); if (requestInfo is not null && includeUrl) attachmentFields.Add(new SlackMessage.SlackAttachmentFields { Title = "Url", Value = requestInfo.GetFullPath(true, true, true) }); diff --git a/src/Exceptionless.Core/Plugins/WebHook/Default/005_SlackPlugin.cs b/src/Exceptionless.Core/Plugins/WebHook/Default/005_SlackPlugin.cs index 91a0fd349b..45c031a826 100644 --- a/src/Exceptionless.Core/Plugins/WebHook/Default/005_SlackPlugin.cs +++ b/src/Exceptionless.Core/Plugins/WebHook/Default/005_SlackPlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Pipeline; +using System.Text.Json; +using Exceptionless.Core.Pipeline; using Exceptionless.Core.Plugins.Formatting; using Microsoft.Extensions.Logging; @@ -8,10 +9,12 @@ namespace Exceptionless.Core.Plugins.WebHook; public sealed class SlackPlugin : WebHookDataPluginBase { private readonly FormattingPluginManager _pluginManager; + private readonly JsonSerializerOptions _jsonOptions; - public SlackPlugin(FormattingPluginManager pluginManager, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public SlackPlugin(FormattingPluginManager pluginManager, JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { _pluginManager = pluginManager; + _jsonOptions = jsonOptions; } public override Task CreateFromEventAsync(WebHookDataContext ctx) @@ -19,7 +22,7 @@ public SlackPlugin(FormattingPluginManager pluginManager, AppOptions options, IL if (String.IsNullOrEmpty(ctx.WebHook.Url) || !ctx.WebHook.Url.EndsWith("/slack")) return Task.FromResult(null); - var error = ctx.Event?.GetError(); + var error = ctx.Event?.GetError(_jsonOptions); if (error is null) { ctx.IsCancelled = true; diff --git a/src/Exceptionless.Core/Plugins/WebHook/Default/010_VersionOnePlugin.cs b/src/Exceptionless.Core/Plugins/WebHook/Default/010_VersionOnePlugin.cs index 04a9414370..c845dcd872 100644 --- a/src/Exceptionless.Core/Plugins/WebHook/Default/010_VersionOnePlugin.cs +++ b/src/Exceptionless.Core/Plugins/WebHook/Default/010_VersionOnePlugin.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -8,20 +9,25 @@ namespace Exceptionless.Core.Plugins.WebHook; [Priority(10)] public sealed class VersionOnePlugin : WebHookDataPluginBase { - public VersionOnePlugin(AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + private readonly JsonSerializerOptions _jsonOptions; + + public VersionOnePlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + { + _jsonOptions = jsonOptions; + } public override Task CreateFromEventAsync(WebHookDataContext ctx) { if (!String.Equals(ctx.WebHook.Version, Models.WebHook.KnownVersions.Version1)) return Task.FromResult(null); - var error = ctx.Event?.GetError(); + var error = ctx.Event?.GetError(_jsonOptions); if (error is null) return Task.FromResult(null); var ev = ctx.Event!; - var requestInfo = ev.GetRequestInfo(); - var environmentInfo = ev.GetEnvironmentInfo(); + var requestInfo = ev.GetRequestInfo(_jsonOptions); + var environmentInfo = ev.GetEnvironmentInfo(_jsonOptions); return Task.FromResult(new VersionOneWebHookEvent(_options.BaseURL) { diff --git a/src/Exceptionless.Core/Serialization/LowerCaseUnderscoreNamingPolicy.cs b/src/Exceptionless.Core/Serialization/LowerCaseUnderscoreNamingPolicy.cs new file mode 100644 index 0000000000..77952a322e --- /dev/null +++ b/src/Exceptionless.Core/Serialization/LowerCaseUnderscoreNamingPolicy.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using Exceptionless.Core.Extensions; + +namespace Exceptionless.Core.Serialization; + +/// +/// A JSON naming policy that converts PascalCase to lower_case_underscore format. +/// This uses the existing ToLowerUnderscoredWords extension method to maintain +/// API compatibility with legacy Newtonsoft.Json serialization. +/// +/// Note: This implementation treats each uppercase letter individually, so: +/// - "OSName" becomes "o_s_name" (not "os_name") +/// - "EnableSSL" becomes "enable_s_s_l" (not "enable_ssl") +/// - "BaseURL" becomes "base_u_r_l" (not "base_url") +/// - "PropertyName" becomes "property_name" +/// +/// This matches the legacy behavior. See https://github.com/exceptionless/Exceptionless.Net/issues/2 +/// for discussion on future improvements. +/// +public sealed class LowerCaseUnderscoreNamingPolicy : JsonNamingPolicy +{ + public static LowerCaseUnderscoreNamingPolicy Instance { get; } = new(); + + public override string ConvertName(string name) + { + return name.ToLowerUnderscoredWords(); + } +} diff --git a/src/Exceptionless.Core/Serialization/ObjectToInferredTypesConverter.cs b/src/Exceptionless.Core/Serialization/ObjectToInferredTypesConverter.cs new file mode 100644 index 0000000000..f450a87777 --- /dev/null +++ b/src/Exceptionless.Core/Serialization/ObjectToInferredTypesConverter.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Exceptionless.Core.Models; + +namespace Exceptionless.Core.Serialization; + +/// +/// A high-performance System.Text.Json converter that deserializes object-typed properties +/// into appropriate .NET types instead of the default behavior. +/// +/// +/// +/// By default, System.Text.Json deserializes properties typed as object into , +/// which requires additional handling to extract values. This converter infers the actual type from the JSON +/// token and deserializes directly to native .NET types: +/// +/// +/// true/false +/// Numbers → (if fits) or +/// Strings with ISO 8601 date format → +/// Other strings → +/// nullnull +/// Objects → with +/// Arrays → of +/// +/// +/// This approach enables GetValue<T> in to work correctly +/// by re-serializing the dictionary and deserializing to the target type with proper naming policies. +/// +/// +/// +/// +/// var options = new JsonSerializerOptions +/// { +/// Converters = { new ObjectToInferredTypesConverter() } +/// }; +/// +/// // Deserializing { "count": 42, "name": "test" } into Dictionary<string, object> +/// // Results in: { "count": (long)42, "name": "test" } instead of JsonElement +/// +/// +/// +public sealed class ObjectToInferredTypesConverter : JsonConverter +{ + /// + public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.Number => ReadNumber(ref reader), + JsonTokenType.String => ReadString(ref reader), + JsonTokenType.Null => null, + JsonTokenType.StartObject => ReadObject(ref reader, options), + JsonTokenType.StartArray => ReadArray(ref reader, options), + _ => JsonDocument.ParseValue(ref reader).RootElement.Clone() + }; + } + + /// + public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + // Handle JsonElement pass-through (may come from partial deserialization) + if (value is JsonElement element) + { + element.WriteTo(writer); + return; + } + + // Serialize using the runtime type to get proper converter handling + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } + + /// + /// Reads a JSON number, preferring for integers and for decimals. + /// + private static object ReadNumber(ref Utf8JsonReader reader) + { + // Try smallest to largest integer types first for optimal boxing + if (reader.TryGetInt32(out int i)) + return i; + + if (reader.TryGetInt64(out long l)) + return l; + + // Try decimal for precise values (e.g., financial data) before double + if (reader.TryGetDecimal(out decimal d)) + return d; + + // Fall back to double for floating-point + return reader.GetDouble(); + } + + /// + /// Reads a JSON string, attempting to parse as for ISO 8601 dates. + /// + private static object? ReadString(ref Utf8JsonReader reader) + { + // Attempt DateTimeOffset parsing for ISO 8601 formatted strings + if (reader.TryGetDateTimeOffset(out DateTimeOffset dateTimeOffset)) + return dateTimeOffset; + + if (reader.TryGetDateTime(out var dt)) + return dt; + + return reader.GetString(); + } + + /// + /// Recursively reads a JSON object into a case-insensitive . + /// + /// + /// Uses for property name matching, + /// consistent with behavior. + /// + private static Dictionary ReadObject(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + return dictionary; + + if (reader.TokenType != JsonTokenType.PropertyName) + continue; + + string propertyName = reader.GetString() ?? string.Empty; + + if (!reader.Read()) + continue; + + dictionary[propertyName] = ReadValue(ref reader, options); + } + + return dictionary; + } + + /// + /// Recursively reads a JSON array into a of objects. + /// + private static List ReadArray(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var list = new List(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + return list; + + list.Add(ReadValue(ref reader, options)); + } + + return list; + } + + /// + /// Reads a single JSON value of any type, dispatching to the appropriate reader method. + /// + private static object? ReadValue(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.Number => ReadNumber(ref reader), + JsonTokenType.String => ReadString(ref reader), + JsonTokenType.Null => null, + JsonTokenType.StartObject => ReadObject(ref reader, options), + JsonTokenType.StartArray => ReadArray(ref reader, options), + _ => JsonDocument.ParseValue(ref reader).RootElement.Clone() + }; + } +} diff --git a/src/Exceptionless.Core/Utility/ErrorSignature.cs b/src/Exceptionless.Core/Utility/ErrorSignature.cs index 5cc4ac7de8..9aa7d3c9f1 100644 --- a/src/Exceptionless.Core/Utility/ErrorSignature.cs +++ b/src/Exceptionless.Core/Utility/ErrorSignature.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; @@ -9,12 +10,14 @@ public class ErrorSignature { private readonly HashSet _userNamespaces; private readonly HashSet _userCommonMethods; + private readonly JsonSerializerOptions _jsonOptions; private static readonly string[] _defaultNonUserNamespaces = ["System", "Microsoft"]; // TODO: Add support for user public key token on signed assemblies - public ErrorSignature(Error error, IEnumerable? userNamespaces = null, IEnumerable? userCommonMethods = null, bool emptyNamespaceIsUserMethod = true, bool shouldFlagSignatureTarget = true) + public ErrorSignature(Error error, JsonSerializerOptions jsonOptions, IEnumerable? userNamespaces = null, IEnumerable? userCommonMethods = null, bool emptyNamespaceIsUserMethod = true, bool shouldFlagSignatureTarget = true) { Error = error ?? throw new ArgumentNullException(nameof(error)); + _jsonOptions = jsonOptions ?? throw new ArgumentNullException(nameof(jsonOptions)); _userNamespaces = userNamespaces is null ? [] @@ -177,7 +180,7 @@ private void AddSpecialCaseDetails(InnerError error) if (!error.Data.ContainsKey(Error.KnownDataKeys.ExtraProperties)) return; - var extraProperties = error.Data.GetValue>(Error.KnownDataKeys.ExtraProperties); + var extraProperties = error.Data.GetValue>(Error.KnownDataKeys.ExtraProperties, _jsonOptions); if (extraProperties is null) { error.Data.Remove(Error.KnownDataKeys.ExtraProperties); diff --git a/src/Exceptionless.Web/Controllers/EventController.cs b/src/Exceptionless.Web/Controllers/EventController.cs index 08713d9da3..f62b1e3275 100644 --- a/src/Exceptionless.Web/Controllers/EventController.cs +++ b/src/Exceptionless.Web/Controllers/EventController.cs @@ -18,6 +18,7 @@ using Exceptionless.Web.Extensions; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; +using Exceptionless.Web.Utility.OpenApi; using FluentValidation; using Foundatio.Caching; using Foundatio.Queues; diff --git a/src/Exceptionless.Web/Controllers/StackController.cs b/src/Exceptionless.Web/Controllers/StackController.cs index 24e02d1f94..4d2493e90a 100644 --- a/src/Exceptionless.Web/Controllers/StackController.cs +++ b/src/Exceptionless.Web/Controllers/StackController.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using AutoMapper; using Exceptionless.Core; using Exceptionless.Core.Authorization; @@ -21,7 +22,6 @@ using McSherry.SemanticVersioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json.Linq; namespace Exceptionless.Web.Controllers; @@ -131,14 +131,14 @@ public async Task MarkFixedAsync(string ids, string? version = nul [HttpPost("mark-fixed")] [Consumes("application/json")] [ApiExplorerSettings(IgnoreApi = true)] - public async Task MarkFixedAsync(JObject data) + public async Task MarkFixedAsync(JsonDocument data) { string? id = null; - if (data.TryGetValue("ErrorStack", out var value)) - id = value.Value(); + if (data.RootElement.TryGetProperty("ErrorStack", out var errorStackProp)) + id = errorStackProp.GetString(); - if (data.TryGetValue("Stack", out value)) - id = value.Value(); + if (data.RootElement.TryGetProperty("Stack", out var stackProp)) + id = stackProp.GetString(); if (String.IsNullOrEmpty(id)) return NotFound(); @@ -215,14 +215,14 @@ public async Task AddLinkAsync(string id, ValueFromBody [HttpPost("add-link")] [Consumes("application/json")] [ApiExplorerSettings(IgnoreApi = true)] - public async Task AddLinkAsync(JObject data) + public async Task AddLinkAsync(JsonDocument data) { string? id = null; - if (data.TryGetValue("ErrorStack", out var value)) - id = value.Value(); + if (data.RootElement.TryGetProperty("ErrorStack", out var errorStackProp)) + id = errorStackProp.GetString(); - if (data.TryGetValue("Stack", out value)) - id = value.Value(); + if (data.RootElement.TryGetProperty("Stack", out var stackProp)) + id = stackProp.GetString(); if (String.IsNullOrEmpty(id)) return NotFound(); @@ -230,7 +230,7 @@ public async Task AddLinkAsync(JObject data) if (id.StartsWith("http")) id = id.Substring(id.LastIndexOf('/') + 1); - string? url = data.GetValue("Link")?.Value(); + string? url = data.RootElement.TryGetProperty("Link", out var linkProp) ? linkProp.GetString() : null; return await AddLinkAsync(id, new ValueFromBody(url)); } diff --git a/src/Exceptionless.Web/Controllers/WebHookController.cs b/src/Exceptionless.Web/Controllers/WebHookController.cs index 052d74ae4d..55bd42870d 100644 --- a/src/Exceptionless.Web/Controllers/WebHookController.cs +++ b/src/Exceptionless.Web/Controllers/WebHookController.cs @@ -1,4 +1,5 @@ -using AutoMapper; +using System.Text.Json; +using AutoMapper; using Exceptionless.Core.Authorization; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; @@ -11,7 +12,6 @@ using Foundatio.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json.Linq; namespace Exceptionless.App.Controllers.API; @@ -101,10 +101,10 @@ public Task> DeleteAsync(string ids) [HttpPost("~/api/v1/projecthook/subscribe")] [Consumes("application/json")] [ApiExplorerSettings(IgnoreApi = true)] - public async Task> SubscribeAsync(JObject data, int apiVersion = 1) + public async Task> SubscribeAsync(JsonDocument data, int apiVersion = 1) { - string? eventType = data.GetValue("event")?.Value(); - string? url = data.GetValue("target_url")?.Value(); + string? eventType = data.RootElement.TryGetProperty("event", out var eventProp) ? eventProp.GetString() : null; + string? url = data.RootElement.TryGetProperty("target_url", out var urlProp) ? urlProp.GetString() : null; if (String.IsNullOrEmpty(eventType) || String.IsNullOrEmpty(url)) return BadRequest(); @@ -139,9 +139,9 @@ public async Task> SubscribeAsync(JObject data, int apiVer [HttpPost("~/api/v1/projecthook/unsubscribe")] [Consumes("application/json")] [ApiExplorerSettings(IgnoreApi = true)] - public async Task UnsubscribeAsync(JObject data) + public async Task UnsubscribeAsync(JsonDocument data) { - string? targetUrl = data.GetValue("target_url")?.Value(); + string? targetUrl = data.RootElement.TryGetProperty("target_url", out var urlProp) ? urlProp.GetString() : null; // don't let this anon method delete non-zapier hooks if (targetUrl is null || !targetUrl.StartsWith("https://hooks.zapier.com")) diff --git a/src/Exceptionless.Web/Exceptionless.Web.csproj b/src/Exceptionless.Web/Exceptionless.Web.csproj index 737499a65c..2c658abc63 100644 --- a/src/Exceptionless.Web/Exceptionless.Web.csproj +++ b/src/Exceptionless.Web/Exceptionless.Web.csproj @@ -15,11 +15,11 @@ - + - + @@ -34,7 +34,6 @@ - diff --git a/src/Exceptionless.Web/Hubs/WebSocketConnectionManager.cs b/src/Exceptionless.Web/Hubs/WebSocketConnectionManager.cs index 75f5ae118f..fb5708ab7a 100644 --- a/src/Exceptionless.Web/Hubs/WebSocketConnectionManager.cs +++ b/src/Exceptionless.Web/Hubs/WebSocketConnectionManager.cs @@ -1,8 +1,11 @@ using System.Collections.Concurrent; using System.Net.WebSockets; using System.Text; +using System.Text.Json; using Exceptionless.Core; -using Newtonsoft.Json; +using Exceptionless.Core.Serialization; +using Exceptionless.Web.Utility; +using Foundatio.Serializer; namespace Exceptionless.Web.Hubs; @@ -11,13 +14,14 @@ public class WebSocketConnectionManager : IDisposable private static readonly ArraySegment _keepAliveMessage = new(Encoding.ASCII.GetBytes("{}"), 0, 2); private readonly ConcurrentDictionary _connections = new(); private readonly Timer? _timer; - private readonly JsonSerializerSettings _serializerSettings; + private readonly ITextSerializer _serializer; private readonly ILogger _logger; - public WebSocketConnectionManager(AppOptions options, JsonSerializerSettings serializerSettings, ILoggerFactory loggerFactory) + public WebSocketConnectionManager(AppOptions options, ITextSerializer serializer, ILoggerFactory loggerFactory) { - _serializerSettings = serializerSettings; + _serializer = serializer; _logger = loggerFactory.CreateLogger(); + if (!options.EnableWebSockets) return; @@ -107,6 +111,7 @@ private async Task CloseWebSocketAsync(WebSocket socket) } catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) { + // Ignored } catch (Exception ex) { @@ -119,7 +124,7 @@ private Task SendMessageAsync(WebSocket socket, object message) if (!CanSendWebSocketMessage(socket)) return Task.CompletedTask; - string serializedMessage = JsonConvert.SerializeObject(message, _serializerSettings); + string serializedMessage = _serializer.SerializeToString(message); Task.Factory.StartNew(async () => { if (!CanSendWebSocketMessage(socket)) @@ -134,6 +139,7 @@ private Task SendMessageAsync(WebSocket socket, object message) } catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) { + // Ignored } catch (Exception ex) { diff --git a/src/Exceptionless.Web/Models/Auth/ChangePasswordModel.cs b/src/Exceptionless.Web/Models/Auth/ChangePasswordModel.cs index 992cd26211..8a6bda7f88 100644 --- a/src/Exceptionless.Web/Models/Auth/ChangePasswordModel.cs +++ b/src/Exceptionless.Web/Models/Auth/ChangePasswordModel.cs @@ -5,10 +5,10 @@ namespace Exceptionless.Web.Models; public record ChangePasswordModel : IValidatableObject { [Required, StringLength(100, MinimumLength = 6)] - public required string CurrentPassword { get; init; } + public string CurrentPassword { get; init; } = null!; [Required, StringLength(100, MinimumLength = 6)] - public required string Password { get; init; } + public string Password { get; init; } = null!; public IEnumerable Validate(ValidationContext validationContext) { diff --git a/src/Exceptionless.Web/Models/Auth/ExternalAuthInfo.cs b/src/Exceptionless.Web/Models/Auth/ExternalAuthInfo.cs index b23d1b2acf..8bcf516a0b 100644 --- a/src/Exceptionless.Web/Models/Auth/ExternalAuthInfo.cs +++ b/src/Exceptionless.Web/Models/Auth/ExternalAuthInfo.cs @@ -1,21 +1,23 @@ using System.ComponentModel.DataAnnotations; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; +using System.Text.Json.Serialization; namespace Exceptionless.Web.Models; // NOTE: This will bypass our LowerCaseUnderscorePropertyNamesContractResolver and provide the correct casing. -[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] public record ExternalAuthInfo { [Required] - public required string ClientId { get; init; } + [JsonPropertyName("clientId")] + public string ClientId { get; init; } = null!; [Required] - public required string Code { get; init; } + [JsonPropertyName("code")] + public string Code { get; init; } = null!; [Required] - public required string RedirectUri { get; init; } + [JsonPropertyName("redirectUri")] + public string RedirectUri { get; init; } = null!; + [JsonPropertyName("inviteToken")] public string? InviteToken { get; init; } } diff --git a/src/Exceptionless.Web/Models/Auth/Login.cs b/src/Exceptionless.Web/Models/Auth/Login.cs index b0d72753b3..7e32858857 100644 --- a/src/Exceptionless.Web/Models/Auth/Login.cs +++ b/src/Exceptionless.Web/Models/Auth/Login.cs @@ -8,10 +8,10 @@ public record Login /// The email address or domain username /// [Required] - public required string Email { get; init; } + public string Email { get; init; } = null!; [Required, StringLength(100, MinimumLength = 6)] - public required string Password { get; init; } + public string Password { get; init; } = null!; [StringLength(40, MinimumLength = 40)] public string? InviteToken { get; init; } diff --git a/src/Exceptionless.Web/Models/Auth/ResetPasswordModel.cs b/src/Exceptionless.Web/Models/Auth/ResetPasswordModel.cs index 760b9d3355..8cefaf6c88 100644 --- a/src/Exceptionless.Web/Models/Auth/ResetPasswordModel.cs +++ b/src/Exceptionless.Web/Models/Auth/ResetPasswordModel.cs @@ -5,8 +5,8 @@ namespace Exceptionless.Web.Models; public record ResetPasswordModel { [Required, StringLength(40, MinimumLength = 40)] - public required string PasswordResetToken { get; init; } + public string PasswordResetToken { get; init; } = null!; [Required, StringLength(100, MinimumLength = 6)] - public required string Password { get; init; } + public string Password { get; init; } = null!; } diff --git a/src/Exceptionless.Web/Models/Auth/Signup.cs b/src/Exceptionless.Web/Models/Auth/Signup.cs index 5ffb8400ec..d0c6004a7b 100644 --- a/src/Exceptionless.Web/Models/Auth/Signup.cs +++ b/src/Exceptionless.Web/Models/Auth/Signup.cs @@ -5,5 +5,5 @@ namespace Exceptionless.Web.Models; public record Signup : Login { [Required] - public required string Name { get; init; } + public string Name { get; init; } = null!; } diff --git a/src/Exceptionless.Web/Startup.cs b/src/Exceptionless.Web/Startup.cs index 54abf4b644..f162acc699 100644 --- a/src/Exceptionless.Web/Startup.cs +++ b/src/Exceptionless.Web/Startup.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Security.Claims; +using System.Text.Json; using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; @@ -7,9 +8,11 @@ using Exceptionless.Core.Validation; using Exceptionless.Web.Extensions; using Exceptionless.Web.Hubs; +using Exceptionless.Web.Models; using Exceptionless.Web.Security; using Exceptionless.Web.Utility; using Exceptionless.Web.Utility.Handlers; +using Exceptionless.Web.Utility.OpenApi; using FluentValidation; using Foundatio.Extensions.Hosting.Startup; using Foundatio.Repositories.Exceptions; @@ -20,10 +23,11 @@ using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.NewtonsoftJson; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.AspNetCore.OpenApi; using Microsoft.Net.Http.Headers; using Microsoft.OpenApi; -using Newtonsoft.Json; +using Scalar.AspNetCore; using Serilog; using Serilog.Events; @@ -59,15 +63,27 @@ public void ConfigureServices(IServiceCollection services) services.AddControllers(o => { o.ModelBinderProviders.Insert(0, new CustomAttributesModelBinderProvider()); - o.ModelMetadataDetailsProviders.Add(new NewtonsoftJsonValidationMetadataProvider(new ExceptionlessNamingStrategy())); + o.ModelMetadataDetailsProviders.Add(new SystemTextJsonValidationMetadataProvider(LowerCaseUnderscoreNamingPolicy.Instance)); o.InputFormatters.Insert(0, new RawRequestBodyFormatter()); }) - .AddNewtonsoftJson(o => + .AddJsonOptions(o => { - o.SerializerSettings.DefaultValueHandling = DefaultValueHandling.Include; - o.SerializerSettings.NullValueHandling = NullValueHandling.Include; - o.SerializerSettings.Formatting = Formatting.Indented; - o.SerializerSettings.ContractResolver = Core.Bootstrapper.GetJsonContractResolver(); // TODO: See if we can resolve this from the di. + o.JsonSerializerOptions.PropertyNamingPolicy = LowerCaseUnderscoreNamingPolicy.Instance; + o.JsonSerializerOptions.Converters.Add(new DeltaJsonConverterFactory()); +#if DEBUG + o.JsonSerializerOptions.RespectNullableAnnotations = true; +#endif + }); + + // Have to add this to get the open api json file to be snake case. + services.ConfigureHttpJsonOptions(o => + { + o.SerializerOptions.PropertyNamingPolicy = LowerCaseUnderscoreNamingPolicy.Instance; + o.SerializerOptions.Converters.Add(new DeltaJsonConverterFactory()); + //o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); +#if DEBUG + o.SerializerOptions.RespectNullableAnnotations = true; +#endif }); services.AddProblemDetails(o => o.CustomizeProblemDetails = CustomizeProblemDetails); @@ -94,66 +110,30 @@ public void ConfigureServices(IServiceCollection services) r.ConstraintMap.Add("tokens", typeof(TokensRouteConstraint)); }); - services.AddSwaggerGen(c => + services.AddOpenApi(o => { - c.SwaggerDoc("v2", new OpenApiInfo + // Customize schema names to strip "DeltaOf" prefix (e.g., DeltaOfUpdateToken -> UpdateToken) + o.CreateSchemaReferenceId = typeInfo => { - Title = "Exceptionless API", - Version = "v2", - TermsOfService = new Uri("https://exceptionless.com/terms/"), - Contact = new OpenApiContact + var type = typeInfo.Type; + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Delta<>)) { - Name = "Exceptionless", - Email = String.Empty, - Url = new Uri("https://github.com/exceptionless/Exceptionless") - }, - License = new OpenApiLicense - { - Name = "Apache License 2.0", - Url = new Uri("https://github.com/exceptionless/Exceptionless/blob/main/LICENSE.txt") + var innerType = type.GetGenericArguments()[0]; + return innerType.Name; } - }); - - c.AddSecurityDefinition("Basic", new OpenApiSecurityScheme - { - Description = "Basic HTTP Authentication", - Scheme = "basic", - Type = SecuritySchemeType.Http - }); - c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme - { - Description = "Authorization token. Example: \"Bearer {apikey}\"", - Scheme = "bearer", - Type = SecuritySchemeType.Http - }); - c.AddSecurityDefinition("Token", new OpenApiSecurityScheme - { - Description = "Authorization token. Example: \"Bearer {apikey}\"", - Name = "access_token", - In = ParameterLocation.Query, - Type = SecuritySchemeType.ApiKey - }); - c.AddSecurityRequirement(document => new OpenApiSecurityRequirement - { - { new OpenApiSecuritySchemeReference("Basic", document), [] }, - { new OpenApiSecuritySchemeReference("Bearer", document), [] }, - { new OpenApiSecuritySchemeReference("Token", document), [] } - }); - - string xmlDocPath = Path.Combine(AppContext.BaseDirectory, "Exceptionless.Web.xml"); - if (File.Exists(xmlDocPath)) - c.IncludeXmlComments(xmlDocPath); - - c.IgnoreObsoleteActions(); - c.OperationFilter(); - c.OperationFilter(); - c.SchemaFilter(); - c.DocumentFilter(); + return OpenApiOptions.CreateDefaultSchemaReferenceId(typeInfo); + }; - c.SupportNonNullableReferenceTypes(); + o.AddDocumentTransformer(); + o.AddDocumentTransformer(); + o.AddOperationTransformer(); + o.AddOperationTransformer(); + o.AddSchemaTransformer(); + o.AddSchemaTransformer(); + o.AddSchemaTransformer(); + o.AddSchemaTransformer(); }); - services.AddSwaggerGenNewtonsoftSupport(); var appOptions = AppOptions.ReadFromConfiguration(Configuration); Bootstrapper.RegisterServices(services, appOptions, Log.Logger.ToLoggerFactory()); @@ -214,7 +194,6 @@ ApplicationException applicationException when applicationException.Message.Cont } }); app.UseStatusCodePages(); - app.UseMiddleware(); app.UseOpenTelemetryPrometheusScrapingEndpoint(); @@ -340,14 +319,6 @@ ApplicationException applicationException when applicationException.Message.Cont // Reject event posts in organizations over their max event limits. app.UseMiddleware(); - app.UseSwagger(c => c.RouteTemplate = "docs/{documentName}/swagger.json"); - app.UseSwaggerUI(s => - { - s.RoutePrefix = "docs"; - s.SwaggerEndpoint("/docs/v2/swagger.json", "Exceptionless API"); - s.InjectStylesheet("/docs.css"); - }); - if (options.EnableWebSockets) { app.UseWebSockets(); @@ -356,6 +327,14 @@ ApplicationException applicationException when applicationException.Message.Cont app.UseEndpoints(endpoints => { + endpoints.MapOpenApi("/docs/v2/openapi.json"); + endpoints.MapScalarApiReference("/docs", o => + { + o.WithOpenApiRoutePattern("/docs/{documentName}/openapi.json") + .AddDocument("v2", "Exceptionless API", "/docs/{documentName}/openapi.json", true) + .AddPreferredSecuritySchemes("Bearer"); + }); + endpoints.MapControllers(); endpoints.MapFallback("{**slug:nonfile}", CreateRequestDelegate(endpoints, "/index.html")); }); diff --git a/src/Exceptionless.Web/Utility/AutoValidationActionFilter.cs b/src/Exceptionless.Web/Utility/AutoValidationActionFilter.cs index 896bca5905..9e03dd3af7 100644 --- a/src/Exceptionless.Web/Utility/AutoValidationActionFilter.cs +++ b/src/Exceptionless.Web/Utility/AutoValidationActionFilter.cs @@ -1,6 +1,7 @@ using System.Dynamic; using System.IO.Pipelines; using System.Security.Claims; +using System.Text.Json; using Exceptionless.Core.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; @@ -33,7 +34,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE continue; // We don't support validating JSON Types - if (subject is Newtonsoft.Json.Linq.JToken or DynamicObject) + if (subject is JsonDocument or JsonElement or DynamicObject) continue; (bool isValid, var errors) = await MiniValidator.TryValidateAsync(subject, _serviceProvider, recurse: true); @@ -43,7 +44,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE foreach (var error in errors) { // TODO: Verify nested object keys - // NOTE: Fallback to finding model state errors where the serializer already changed the key, but differs from ModelState like ExternalAuthInfo (without NamingStrategyType) + // NOTE: Fallback to finding model state errors where the serializer already changed the key, but differs from ModelState like ExternalAuthInfo (without NamingStrategyType) var modelStateEntry = context.ModelState[error.Key] ?? context.ModelState[error.Key.ToLowerUnderscoredWords()]; foreach (string errorMessage in error.Value) { diff --git a/src/Exceptionless.Web/Utility/Delta/Delta.cs b/src/Exceptionless.Web/Utility/Delta/Delta.cs index 727d2d955f..38c402227e 100644 --- a/src/Exceptionless.Web/Utility/Delta/Delta.cs +++ b/src/Exceptionless.Web/Utility/Delta/Delta.cs @@ -2,10 +2,9 @@ using System.Collections.Concurrent; using System.Dynamic; +using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Reflection; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace Exceptionless.Web.Utility; @@ -27,7 +26,9 @@ public class Delta : DynamicObject /*, IDelta */ where TEntityType /// /// Initializes a new instance of . /// - public Delta() : this(typeof(TEntityType)) { } + public Delta() : this(typeof(TEntityType)) + { + } /// /// Initializes a new instance of . @@ -79,11 +80,11 @@ public bool TrySetPropertyValue(string name, object? value, TEntityType? target if (value is not null) { - if (value is JToken jToken) + if (value is JsonElement jsonElement) { try { - value = JsonConvert.DeserializeObject(jToken.ToString(), cacheHit.MemberType); + value = JsonSerializer.Deserialize(jsonElement.GetRawText(), cacheHit.MemberType); } catch (Exception) { diff --git a/src/Exceptionless.Web/Utility/Delta/DeltaJsonConverter.cs b/src/Exceptionless.Web/Utility/Delta/DeltaJsonConverter.cs new file mode 100644 index 0000000000..66321c608e --- /dev/null +++ b/src/Exceptionless.Web/Utility/Delta/DeltaJsonConverter.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Exceptionless.Web.Utility; + +/// +/// JsonConverterFactory for Delta<T> types to support System.Text.Json deserialization. +/// +public class DeltaJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + { + return false; + } + + return typeToConvert.GetGenericTypeDefinition() == typeof(Delta<>); + } + + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var entityType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(DeltaJsonConverter<>).MakeGenericType(entityType); + + return (JsonConverter?)Activator.CreateInstance(converterType, options); + } +} + +/// +/// JsonConverter for Delta<T> that reads JSON properties and sets them on the Delta instance. +/// +public class DeltaJsonConverter : JsonConverter> where TEntityType : class +{ + private readonly JsonSerializerOptions _options; + private readonly Dictionary _jsonNameToPropertyName; + + public DeltaJsonConverter(JsonSerializerOptions options) + { + // Create a copy without the converter to avoid infinite recursion + _options = new JsonSerializerOptions(options); + + // Build a mapping from JSON property names (snake_case) to C# property names (PascalCase) + _jsonNameToPropertyName = new Dictionary(StringComparer.OrdinalIgnoreCase); + var entityType = typeof(TEntityType); + foreach (var prop in entityType.GetProperties()) + { + var jsonName = options.PropertyNamingPolicy?.ConvertName(prop.Name) ?? prop.Name; + _jsonNameToPropertyName[jsonName] = prop.Name; + } + } + + public override Delta? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token"); + } + + var delta = new Delta(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected PropertyName token"); + } + + var jsonPropertyName = reader.GetString(); + if (jsonPropertyName is null) + { + throw new JsonException("Property name is null"); + } + + reader.Read(); + + // Convert JSON property name (snake_case) to C# property name (PascalCase) + var propertyName = _jsonNameToPropertyName.TryGetValue(jsonPropertyName, out var mapped) + ? mapped + : jsonPropertyName; + + // Try to get the property type from Delta + if (delta.TryGetPropertyType(propertyName, out var propertyType) && propertyType is not null) + { + var value = JsonSerializer.Deserialize(ref reader, propertyType, _options); + delta.TrySetPropertyValue(propertyName, value); + } + else + { + // Unknown property - read and store as JsonElement + var element = JsonSerializer.Deserialize(ref reader, _options); + delta.UnknownProperties[jsonPropertyName] = element; + } + } + + return delta; + } + + public override void Write(Utf8JsonWriter writer, Delta value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + foreach (var propertyName in value.GetChangedPropertyNames()) + { + if (value.TryGetPropertyValue(propertyName, out var propertyValue)) + { + // Convert property name to snake_case if needed + var jsonPropertyName = options.PropertyNamingPolicy?.ConvertName(propertyName) ?? propertyName; + writer.WritePropertyName(jsonPropertyName); + JsonSerializer.Serialize(writer, propertyValue, _options); + } + } + + foreach (var kvp in value.UnknownProperties) + { + var jsonPropertyName = options.PropertyNamingPolicy?.ConvertName(kvp.Key) ?? kvp.Key; + writer.WritePropertyName(jsonPropertyName); + JsonSerializer.Serialize(writer, kvp.Value, _options); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Exceptionless.Web/Utility/DeltaOperationFilter.cs b/src/Exceptionless.Web/Utility/DeltaOperationFilter.cs deleted file mode 100644 index d972d53aba..0000000000 --- a/src/Exceptionless.Web/Utility/DeltaOperationFilter.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Microsoft.OpenApi; -using Swashbuckle.AspNetCore.SwaggerGen; - -namespace Exceptionless.Web.Utility; - -/// -/// Operation filter that unwraps Delta<T> types to expose the underlying T type in the OpenAPI schema. -/// This enables proper schema generation for PATCH endpoints that use Delta for partial updates. -/// -public class DeltaOperationFilter : IOperationFilter -{ - public void Apply(OpenApiOperation operation, OperationFilterContext context) - { - if (operation.RequestBody?.Content is null) - return; - - foreach (var parameter in context.MethodInfo.GetParameters()) - { - var parameterType = parameter.ParameterType; - - // Check if the parameter is Delta - if (parameterType.IsGenericType && parameterType.GetGenericTypeDefinition() == typeof(Delta<>)) - { - var underlyingType = parameterType.GetGenericArguments()[0]; - - // Generate schema for the underlying type - var schema = context.SchemaGenerator.GenerateSchema(underlyingType, context.SchemaRepository); - - // Replace the Delta schema with the underlying type's schema in all content types - foreach (var content in operation.RequestBody.Content.Values) - { - content.Schema = schema; - } - - break; // Only one body parameter expected - } - } - } -} diff --git a/src/Exceptionless.Web/Utility/Handlers/AllowSynchronousIOMiddleware.cs b/src/Exceptionless.Web/Utility/Handlers/AllowSynchronousIOMiddleware.cs deleted file mode 100644 index a32e12eac5..0000000000 --- a/src/Exceptionless.Web/Utility/Handlers/AllowSynchronousIOMiddleware.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.AspNetCore.Http.Features; - -namespace Exceptionless.Web.Utility.Handlers; - -public class AllowSynchronousIOMiddleware -{ - private readonly RequestDelegate _next; - - public AllowSynchronousIOMiddleware(RequestDelegate next) - { - _next = next; - } - - public Task Invoke(HttpContext context) - { - var syncIOFeature = context.Features.Get(); - if (syncIOFeature is not null) - syncIOFeature.AllowSynchronousIO = true; - - return _next(context); - } -} diff --git a/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs new file mode 100644 index 0000000000..5ea61ae80b --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs @@ -0,0 +1,110 @@ +using System.Reflection; +using Exceptionless.Core.Extensions; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Utility.OpenApi; + +/// +/// Schema transformer that populates Delta<T> schemas with the properties from T. +/// All properties are optional to represent PATCH semantics (partial updates). +/// +public class DeltaSchemaTransformer : IOpenApiSchemaTransformer +{ + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + var type = context.JsonTypeInfo.Type; + + // Check if this is a Delta type + if (!IsDeltaType(type)) + return Task.CompletedTask; + + // Get the inner type T from Delta + var innerType = type.GetGenericArguments().FirstOrDefault(); + if (innerType is null) + return Task.CompletedTask; + + // Set the type to object + schema.Type = JsonSchemaType.Object; + + // Add properties from the inner type + schema.Properties ??= new Dictionary(); + + foreach (var property in innerType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!property.CanRead || !property.CanWrite) + continue; + + var propertySchema = CreateSchemaForType(property.PropertyType); + string propertyName = property.Name.ToLowerUnderscoredWords(); + + schema.Properties[propertyName] = propertySchema; + } + + // Ensure no required array - all properties are optional for PATCH + schema.Required = null; + + return Task.CompletedTask; + } + + private static bool IsDeltaType(Type type) + { + return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Delta<>); + } + + private static OpenApiSchema CreateSchemaForType(Type type) + { + var schema = new OpenApiSchema(); + JsonSchemaType schemaType = default; + + // Handle nullable types + var underlyingType = Nullable.GetUnderlyingType(type); + if (underlyingType is not null) + { + type = underlyingType; + schemaType |= JsonSchemaType.Null; + } + + if (type == typeof(string)) + { + schemaType |= JsonSchemaType.String; + } + else if (type == typeof(bool)) + { + schemaType |= JsonSchemaType.Boolean; + } + else if (type == typeof(int) || type == typeof(long) || type == typeof(short) || type == typeof(byte)) + { + schemaType |= JsonSchemaType.Integer; + } + else if (type == typeof(double) || type == typeof(float) || type == typeof(decimal)) + { + schemaType |= JsonSchemaType.Number; + } + else if (type == typeof(DateTime) || type == typeof(DateTimeOffset)) + { + schemaType |= JsonSchemaType.String; + schema.Format = "date-time"; + } + else if (type == typeof(Guid)) + { + schemaType |= JsonSchemaType.String; + schema.Format = "uuid"; + } + else if (type.IsEnum) + { + schemaType |= JsonSchemaType.String; + } + else if (type.IsArray || (type.IsGenericType && typeof(System.Collections.IEnumerable).IsAssignableFrom(type))) + { + schemaType = JsonSchemaType.Array; + } + else + { + schemaType = JsonSchemaType.Object; + } + + schema.Type = schemaType; + return schema; + } +} diff --git a/src/Exceptionless.Web/Utility/OpenApi/DocumentInfoTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/DocumentInfoTransformer.cs new file mode 100644 index 0000000000..2badff6aed --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/DocumentInfoTransformer.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Utility.OpenApi; + +/// +/// Document transformer that adds API information and security schemes to the OpenAPI document. +/// +public class DocumentInfoTransformer : IOpenApiDocumentTransformer +{ + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + document.Info = new OpenApiInfo + { + Title = "Exceptionless API", + Version = "v2", + TermsOfService = new Uri("https://exceptionless.com/terms/"), + Contact = new OpenApiContact + { + Name = "Exceptionless", + Email = String.Empty, + Url = new Uri("https://github.com/exceptionless/Exceptionless") + }, + License = new OpenApiLicense + { + Name = "Apache License 2.0", + Url = new Uri("https://github.com/exceptionless/Exceptionless/blob/main/LICENSE.txt") + } + }; + + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes = new Dictionary + { + ["Basic"] = new OpenApiSecurityScheme + { + Description = "Basic HTTP Authentication", + Scheme = "basic", + Type = SecuritySchemeType.Http + }, + ["Bearer"] = new OpenApiSecurityScheme + { + Description = "Authorization token. Example: \"Bearer {apikey}\"", + Scheme = "bearer", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http + }, + ["Token"] = new OpenApiSecurityScheme + { + Description = "Authorization token. Example: \"Bearer {apikey}\"", + Name = "access_token", + In = ParameterLocation.Query, + Type = SecuritySchemeType.ApiKey + } + }; + + document.Security ??= []; + return Task.CompletedTask; + } +} diff --git a/src/Exceptionless.Web/Utility/OpenApi/ReadOnlyPropertySchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/ReadOnlyPropertySchemaTransformer.cs new file mode 100644 index 0000000000..aeed37d9c3 --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/ReadOnlyPropertySchemaTransformer.cs @@ -0,0 +1,38 @@ +using Exceptionless.Core.Extensions; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Utility.OpenApi; + +/// +/// Schema transformer that adds readOnly: true to properties that have only getters (no setters). +/// This helps API consumers understand which properties are computed and cannot be set. +/// +public class ReadOnlyPropertySchemaTransformer : IOpenApiSchemaTransformer +{ + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (schema.Properties is null || schema.Properties.Count == 0) + return Task.CompletedTask; + + var type = context.JsonTypeInfo.Type; + if (!type.IsClass) + return Task.CompletedTask; + + foreach (var property in type.GetProperties()) + { + if (!property.CanRead || property.CanWrite) + continue; + + // Find the matching schema property (property names are in snake_case in the schema) + string schemaPropertyName = property.Name.ToLowerUnderscoredWords(); + if (schema.Properties.TryGetValue(schemaPropertyName, out var propertySchema) && propertySchema is OpenApiSchema mutableSchema) + { + // Cast to OpenApiSchema to access mutable properties + mutableSchema.ReadOnly = true; + } + } + + return Task.CompletedTask; + } +} diff --git a/src/Exceptionless.Web/Utility/OpenApi/RemoveProblemJsonFromSuccessResponsesTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/RemoveProblemJsonFromSuccessResponsesTransformer.cs new file mode 100644 index 0000000000..f72ba68bff --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/RemoveProblemJsonFromSuccessResponsesTransformer.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Utility.OpenApi; + +/// +/// Document transformer that removes application/problem+json content type from successful (2xx) responses. +/// The problem+json media type (RFC 7807) should only be used for error responses. +/// +public class RemoveProblemJsonFromSuccessResponsesTransformer : IOpenApiDocumentTransformer +{ + private const string ProblemJsonContentType = "application/problem+json"; + + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + foreach (var operations in document.Paths.Select(p => p.Value.Operations)) + { + if (operations is null) + continue; + + foreach (var response in operations.Values.SelectMany(v => v.Responses ?? []).Where(r => r.Key.StartsWith('2') && r.Value.Content is not null)) + { + // Only process 2xx success responses + response.Value.Content?.Remove(ProblemJsonContentType); + } + } + + return Task.CompletedTask; + } +} diff --git a/src/Exceptionless.Web/Utility/OpenApi/RequestBodyContentOperationTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/RequestBodyContentOperationTransformer.cs new file mode 100644 index 0000000000..a9d408d539 --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/RequestBodyContentOperationTransformer.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Utility.OpenApi; + +/// +/// Operation transformer that handles endpoints with [RequestBodyContent] attribute +/// to properly set the request body schema for raw content types. +/// +public class RequestBodyContentOperationTransformer : IOpenApiOperationTransformer +{ + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var methodInfo = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .FirstOrDefault(); + + if (methodInfo is null && context.Description.ActionDescriptor is Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor controllerDescriptor) + { + // For controller actions, try to get from ControllerActionDescriptor + methodInfo = controllerDescriptor.MethodInfo; + } + + if (methodInfo is null) + return Task.CompletedTask; + + bool hasRequestBodyContent = methodInfo.GetCustomAttributes(typeof(RequestBodyContentAttribute), true).Any(); + if (!hasRequestBodyContent) + return Task.CompletedTask; + + var consumesAttribute = methodInfo.GetCustomAttributes(typeof(ConsumesAttribute), true).FirstOrDefault() as ConsumesAttribute; + if (consumesAttribute is null) + return Task.CompletedTask; + + operation.RequestBody = new OpenApiRequestBody + { + Required = true, + Content = new Dictionary() + }; + + foreach (string contentType in consumesAttribute.ContentTypes) + { + operation.RequestBody.Content!.Add(contentType, new OpenApiMediaType + { + Schema = new OpenApiSchema { Type = JsonSchemaType.String, Example = JsonValue.Create(String.Empty) } + }); + } + + return Task.CompletedTask; + } +} + +/// +/// Attribute to mark endpoints that accept raw request body content. +/// +[AttributeUsage(AttributeTargets.Method)] +public class RequestBodyContentAttribute : Attribute +{ +} diff --git a/src/Exceptionless.Web/Utility/OpenApi/UniqueItemsSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/UniqueItemsSchemaTransformer.cs new file mode 100644 index 0000000000..7cf8472260 --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/UniqueItemsSchemaTransformer.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Utility.OpenApi; + +/// +/// Schema transformer that adds uniqueItems: true to HashSet and ISet properties. +/// This maintains compatibility with the previous Swashbuckle-generated schema. +/// +public class UniqueItemsSchemaTransformer : IOpenApiSchemaTransformer +{ + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + var type = context.JsonTypeInfo.Type; + + // Check if this is a Set type (HashSet, ISet, etc.) + if (IsSetType(type)) + schema.UniqueItems = true; + + return Task.CompletedTask; + } + + private static bool IsSetType(Type type) + { + if (type.IsGenericType) + { + var genericTypeDef = type.GetGenericTypeDefinition(); + if (genericTypeDef == typeof(HashSet<>) || + genericTypeDef == typeof(ISet<>) || + genericTypeDef == typeof(SortedSet<>)) + { + return true; + } + } + + // Check if it implements ISet + return type.GetInterfaces().Any(i => + i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ISet<>)); + } +} diff --git a/src/Exceptionless.Web/Utility/OpenApi/XEnumNamesSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/XEnumNamesSchemaTransformer.cs new file mode 100644 index 0000000000..5655cd41e7 --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/XEnumNamesSchemaTransformer.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Utility.OpenApi; + +/// +/// Schema transformer that adds x-enumNames extension to enum schemas. +/// This enables swagger-typescript-api and similar generators to create +/// meaningful enum member names instead of Value0, Value1, etc. +/// +public class XEnumNamesSchemaTransformer : IOpenApiSchemaTransformer +{ + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + var type = context.JsonTypeInfo.Type; + if (!type.IsEnum) + return Task.CompletedTask; + + if (schema.Enum is null || schema.Enum.Count == 0) + return Task.CompletedTask; + + string[] names = Enum.GetNames(type); + var enumNamesArray = new JsonArray(); + + foreach (string name in names) + enumNamesArray.Add(name); + + schema.Extensions ??= new Dictionary(); + schema.Extensions["x-enumNames"] = new JsonNodeExtension(enumNamesArray); + + return Task.CompletedTask; + } +} diff --git a/src/Exceptionless.Web/Utility/OpenApi/XmlDocumentationOperationTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/XmlDocumentationOperationTransformer.cs new file mode 100644 index 0000000000..3516085b2f --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/XmlDocumentationOperationTransformer.cs @@ -0,0 +1,133 @@ +using System.Reflection; +using System.Xml.Linq; +using Foundatio.AsyncEx; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Utility.OpenApi; + +/// +/// Operation transformer that reads XML documentation <response> tags +/// and adds them to OpenAPI operation responses. +/// +public class XmlDocumentationOperationTransformer : IOpenApiOperationTransformer +{ + private static readonly Dictionary _xmlDocCache = new(); + private static readonly AsyncLock _lock = new(); + + public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var methodInfo = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .FirstOrDefault(); + + if (methodInfo is null && context.Description.ActionDescriptor is Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor controllerDescriptor) + { + // For controller actions, try to get from ControllerActionDescriptor + methodInfo = controllerDescriptor.MethodInfo; + } + + if (methodInfo is null) + return; + + var xmlDoc = await GetXmlDocumentationAsync(methodInfo.DeclaringType?.Assembly); + if (xmlDoc is null) + return; + + string methodMemberName = GetMemberName(methodInfo); + var memberElement = xmlDoc.Descendants("member") + .FirstOrDefault(m => m.Attribute("name")?.Value == methodMemberName); + + if (memberElement is null) + return; + + var responseElements = memberElement.Elements("response"); + foreach (var responseElement in responseElements) + { + var codeAttribute = responseElement.Attribute("code"); + if (codeAttribute is null) + continue; + + string statusCode = codeAttribute.Value; + string description = responseElement.Value.Trim(); + + // Skip if Responses is null or this response already exists + if (operation.Responses is null || operation.Responses.ContainsKey(statusCode)) + continue; + + operation.Responses[statusCode] = new OpenApiResponse + { + Description = description + }; + } + } + + private async Task GetXmlDocumentationAsync(Assembly? assembly) + { + if (assembly is null) + return null; + + string? assemblyName = assembly.GetName().Name; + if (assemblyName is null) + return null; + + using (await _lock.LockAsync()) + { + if (_xmlDocCache.TryGetValue(assemblyName, out var cachedDoc)) + return cachedDoc; + + string xmlPath = Path.Combine(AppContext.BaseDirectory, $"{assemblyName}.xml"); + if (!File.Exists(xmlPath)) + return null; + + try + { + var doc = XDocument.Load(xmlPath); + _xmlDocCache[assemblyName] = doc; + return doc; + } + catch + { + return null; + } + } + } + + private static string GetMemberName(MethodInfo methodInfo) + { + var declaringType = methodInfo.DeclaringType; + if (declaringType is null) + return String.Empty; + + string? typeName = declaringType.FullName?.Replace('+', '.'); + var parameters = methodInfo.GetParameters(); + + if (parameters.Length == 0) + return $"M:{typeName}.{methodInfo.Name}"; + + string parameterTypes = String.Join(",", parameters.Select(p => GetParameterTypeName(p.ParameterType))); + return $"M:{typeName}.{methodInfo.Name}({parameterTypes})"; + } + + private static string GetParameterTypeName(Type type) + { + if (type.IsGenericType) + { + string? genericTypeName = type.GetGenericTypeDefinition().FullName; + if (genericTypeName is null) + return type.Name; + + int backtickIndex = genericTypeName.IndexOf('`'); + if (backtickIndex > 0) + genericTypeName = genericTypeName[..backtickIndex]; + + string genericArgs = String.Join(",", type.GetGenericArguments().Select(GetParameterTypeName)); + return $"{genericTypeName}{{{genericArgs}}}"; + } + + if (type.IsArray) + return $"{GetParameterTypeName(type.GetElementType()!)}[]"; + + return type.FullName ?? type.Name; + } +} diff --git a/src/Exceptionless.Web/Utility/RemoveProblemJsonFromSuccessResponsesFilter.cs b/src/Exceptionless.Web/Utility/RemoveProblemJsonFromSuccessResponsesFilter.cs deleted file mode 100644 index d3b0983f0a..0000000000 --- a/src/Exceptionless.Web/Utility/RemoveProblemJsonFromSuccessResponsesFilter.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.OpenApi; -using Swashbuckle.AspNetCore.SwaggerGen; - -namespace Exceptionless.Web.Utility; - -/// -/// Removes application/problem+json content type from successful (2xx) responses. -/// The problem+json media type (RFC 7807) should only be used for error responses. -/// -public class RemoveProblemJsonFromSuccessResponsesFilter : IDocumentFilter -{ - private const string ProblemJsonContentType = "application/problem+json"; - - public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) - { - if (swaggerDoc.Paths is null) - return; - - foreach (var path in swaggerDoc.Paths) - { - if (path.Value?.Operations is null) - continue; - - foreach (var operation in path.Value.Operations.Values) - { - if (operation?.Responses is null) - continue; - - foreach (var response in operation.Responses) - { - // Only process 2xx success responses - if (response.Key.StartsWith('2') && response.Value?.Content is not null) - { - response.Value.Content.Remove(ProblemJsonContentType); - } - } - } - } - } -} diff --git a/src/Exceptionless.Web/Utility/RequestBodyOperationFilter.cs b/src/Exceptionless.Web/Utility/RequestBodyOperationFilter.cs deleted file mode 100644 index 3298480620..0000000000 --- a/src/Exceptionless.Web/Utility/RequestBodyOperationFilter.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Text.Json.Nodes; -using Microsoft.AspNetCore.Mvc; -using Microsoft.OpenApi; -using Swashbuckle.AspNetCore.SwaggerGen; - -public class RequestBodyContentAttribute : Attribute -{ -} - -public class RequestBodyOperationFilter : IOperationFilter -{ - public void Apply(OpenApiOperation operation, OperationFilterContext context) - { - object? attributes = context.MethodInfo.GetCustomAttributes(typeof(RequestBodyContentAttribute), true).FirstOrDefault(); - if (attributes is null) - return; - - var consumesAttribute = context.MethodInfo.GetCustomAttributes(typeof(ConsumesAttribute), true).FirstOrDefault() as ConsumesAttribute; - if (consumesAttribute is null) - return; - - operation.RequestBody = new OpenApiRequestBody - { - Required = true, - Content = new Dictionary() - }; - - foreach (string contentType in consumesAttribute.ContentTypes) - { - operation.RequestBody.Content!.Add(contentType, new OpenApiMediaType - { - Schema = new OpenApiSchema { Type = JsonSchemaType.String, Example = JsonValue.Create(String.Empty) } - }); - } - } -} diff --git a/src/Exceptionless.Web/Utility/XEnumNamesSchemaFilter.cs b/src/Exceptionless.Web/Utility/XEnumNamesSchemaFilter.cs deleted file mode 100644 index b1594a49e6..0000000000 --- a/src/Exceptionless.Web/Utility/XEnumNamesSchemaFilter.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Text.Json.Nodes; -using Microsoft.OpenApi; -using Swashbuckle.AspNetCore.SwaggerGen; - -namespace Exceptionless.Web.Utility; - -/// -/// Schema filter that adds x-enumNames extension to numeric enum schemas. -/// This enables swagger-typescript-api and similar generators to create -/// meaningful enum member names instead of Value0, Value1, etc. -/// -public class XEnumNamesSchemaFilter : ISchemaFilter -{ - public void Apply(IOpenApiSchema schema, SchemaFilterContext context) - { - if (schema is not OpenApiSchema concrete) - return; - - var type = context.Type; - if (type is null || !type.IsEnum) - return; - - if (concrete.Enum is null || concrete.Enum.Count == 0) - return; - - var names = Enum.GetNames(type); - var enumNamesArray = new JsonArray(); - - foreach (var name in names) - { - enumNamesArray.Add(name); - } - - concrete.Extensions ??= new Dictionary(); - concrete.Extensions["x-enumNames"] = new JsonNodeExtension(enumNamesArray); - } -} diff --git a/tests/Exceptionless.Tests/Controllers/Data/swagger.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json similarity index 76% rename from tests/Exceptionless.Tests/Controllers/Data/swagger.json rename to tests/Exceptionless.Tests/Controllers/Data/openapi.json index f205c4cd47..724749e245 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/swagger.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -1,5 +1,5 @@ { - "openapi": "3.0.4", + "openapi": "3.1.1", "info": { "title": "Exceptionless API", "termsOfService": "https://exceptionless.com/terms/", @@ -14,22 +14,744 @@ }, "version": "v2" }, + "servers": [ + { + "url": "http://localhost/" + } + ], "paths": { + "/api/v2/organizations/{organizationId}/tokens": { + "get": { + "tags": [ + "Token" + ], + "summary": "Get by organization", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewToken" + } + } + } + } + }, + "404": { + "description": "The organization could not be found." + } + } + }, + "post": { + "tags": [ + "Token" + ], + "summary": "Create for organization", + "description": "This is a helper action that makes it easier to create a token for a specific organization.\nYou may also specify a scope when creating a token. There are three valid scopes: client, user and admin.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The token.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NewToken" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NewToken" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewToken" + } + } + } + }, + "400": { + "description": "An error occurred while creating the token." + }, + "409": { + "description": "The token already exists." + } + } + } + }, + "/api/v2/projects/{projectId}/tokens": { + "get": { + "tags": [ + "Token" + ], + "summary": "Get by project", + "parameters": [ + { + "name": "projectId", + "in": "path", + "description": "The identifier of the project.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewToken" + } + } + } + } + }, + "404": { + "description": "The project could not be found." + } + } + }, + "post": { + "tags": [ + "Token" + ], + "summary": "Create for project", + "description": "This is a helper action that makes it easier to create a token for a specific project.\nYou may also specify a scope when creating a token. There are three valid scopes: client, user and admin.", + "parameters": [ + { + "name": "projectId", + "in": "path", + "description": "The identifier of the project.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The token.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NewToken" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NewToken" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewToken" + } + } + } + }, + "400": { + "description": "An error occurred while creating the token." + }, + "404": { + "description": "The project could not be found." + }, + "409": { + "description": "The token already exists." + } + } + } + }, + "/api/v2/projects/{projectId}/tokens/default": { + "get": { + "tags": [ + "Token" + ], + "summary": "Get a projects default token", + "parameters": [ + { + "name": "projectId", + "in": "path", + "description": "The identifier of the project.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewToken" + } + } + } + }, + "404": { + "description": "The project could not be found." + } + } + } + }, + "/api/v2/tokens/{id}": { + "get": { + "tags": [ + "Token" + ], + "summary": "Get by id", + "operationId": "GetTokenById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the token.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d-]{24,40}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewToken" + } + } + } + }, + "404": { + "description": "The token could not be found." + } + } + }, + "patch": { + "tags": [ + "Token" + ], + "summary": "Update", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the token.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d-]{24,40}(,[a-zA-Z\\d-]{24,40})*$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The changes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateToken" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateToken" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewToken" + } + } + } + }, + "400": { + "description": "An error occurred while updating the token." + }, + "404": { + "description": "The token could not be found." + } + } + }, + "put": { + "tags": [ + "Token" + ], + "summary": "Update", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the token.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d-]{24,40}(,[a-zA-Z\\d-]{24,40})*$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The changes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateToken" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateToken" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewToken" + } + } + } + }, + "400": { + "description": "An error occurred while updating the token." + }, + "404": { + "description": "The token could not be found." + } + } + } + }, + "/api/v2/tokens": { + "post": { + "tags": [ + "Token" + ], + "summary": "Create", + "description": "To create a new token, you must specify an organization_id. There are three valid scopes: client, user and admin.", + "requestBody": { + "description": "The token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewToken" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/NewToken" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewToken" + } + } + } + }, + "400": { + "description": "An error occurred while creating the token." + }, + "409": { + "description": "The token already exists." + } + } + } + }, + "/api/v2/tokens/{ids}": { + "delete": { + "tags": [ + "Token" + ], + "summary": "Remove", + "parameters": [ + { + "name": "ids", + "in": "path", + "description": "A comma-delimited list of token identifiers.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d-]{24,40}(,[a-zA-Z\\d-]{24,40})*$", + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } + } + }, + "400": { + "description": "One or more validation errors occurred." + }, + "404": { + "description": "One or more tokens were not found." + }, + "500": { + "description": "An error occurred while deleting one or more tokens." + } + } + } + }, + "/api/v2/projects/{projectId}/webhooks": { + "get": { + "tags": [ + "WebHook" + ], + "summary": "Get by project", + "parameters": [ + { + "name": "projectId", + "in": "path", + "description": "The identifier of the project.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebHook" + } + } + } + } + }, + "404": { + "description": "The project could not be found." + } + } + } + }, + "/api/v2/webhooks/{id}": { + "get": { + "tags": [ + "WebHook" + ], + "summary": "Get by id", + "operationId": "GetWebHookById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the web hook.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebHook" + } + } + } + }, + "404": { + "description": "The web hook could not be found." + } + } + } + }, + "/api/v2/webhooks": { + "post": { + "tags": [ + "WebHook" + ], + "summary": "Create", + "requestBody": { + "description": "The web hook.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewWebHook" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/NewWebHook" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebHook" + } + } + } + }, + "400": { + "description": "An error occurred while creating the web hook." + }, + "409": { + "description": "The web hook already exists." + } + } + } + }, + "/api/v2/webhooks/{ids}": { + "delete": { + "tags": [ + "WebHook" + ], + "summary": "Remove", + "parameters": [ + { + "name": "ids", + "in": "path", + "description": "A comma-delimited list of web hook identifiers.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } + } + }, + "400": { + "description": "One or more validation errors occurred." + }, + "404": { + "description": "One or more web hooks were not found." + }, + "500": { + "description": "An error occurred while deleting one or more web hooks." + } + } + } + }, "/api/v2/auth/login": { "post": { "tags": [ "Auth" ], "summary": "Login", - "description": "Log in with your email address and password to generate a token scoped with your users roles.\n \n```{ \"email\": \"noreply@exceptionless.io\", \"password\": \"exceptionless\" }```\n \nThis token can then be used to access the api. You can use this token in the header (bearer authentication)\nor append it onto the query string: ?access_token=MY_TOKEN\n \nPlease note that you can also use this token on the documentation site by placing it in the\nheaders api_key input box.", + "description": "Log in with your email address and password to generate a token scoped with your users roles.\n\n```{ \"email\": \"noreply@exceptionless.io\", \"password\": \"exceptionless\" }```\n\nThis token can then be used to access the api. You can use this token in the header (bearer authentication)\nor append it onto the query string: ?access_token=MY_TOKEN\n\nPlease note that you can also use this token on the documentation site by placing it in the\nheaders api_key input box.", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Login" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/Login" + } } - } + }, + "required": true }, "responses": { "200": { @@ -59,7 +781,10 @@ "summary": "Logout the current user and remove the current access token", "responses": { "200": { - "description": "User successfully logged-out" + "description": "User successfully logged-out", + "content": { + "application/json": { } + } }, "401": { "description": "User not logged in" @@ -82,8 +807,14 @@ "schema": { "$ref": "#/components/schemas/Signup" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/Signup" + } } - } + }, + "required": true }, "responses": { "200": { @@ -120,8 +851,14 @@ "schema": { "$ref": "#/components/schemas/ExternalAuthInfo" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ExternalAuthInfo" + } } - } + }, + "required": true }, "responses": { "200": { @@ -155,8 +892,14 @@ "schema": { "$ref": "#/components/schemas/ExternalAuthInfo" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ExternalAuthInfo" + } } - } + }, + "required": true }, "responses": { "200": { @@ -190,8 +933,14 @@ "schema": { "$ref": "#/components/schemas/ExternalAuthInfo" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ExternalAuthInfo" + } } - } + }, + "required": true }, "responses": { "200": { @@ -225,8 +974,14 @@ "schema": { "$ref": "#/components/schemas/ExternalAuthInfo" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ExternalAuthInfo" + } } - } + }, + "required": true }, "responses": { "200": { @@ -271,10 +1026,16 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + "$ref": "#/components/schemas/ValueFromBodyOfstring" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ValueFromBodyOfstring" } } - } + }, + "required": true }, "responses": { "200": { @@ -305,8 +1066,14 @@ "schema": { "$ref": "#/components/schemas/ChangePasswordModel" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordModel" + } } - } + }, + "required": true }, "responses": { "200": { @@ -345,7 +1112,10 @@ ], "responses": { "200": { - "description": "Forgot password email was sent." + "description": "Forgot password email was sent.", + "content": { + "application/json": { } + } }, "400": { "description": "Invalid email address." @@ -365,12 +1135,21 @@ "schema": { "$ref": "#/components/schemas/ResetPasswordModel" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordModel" + } } - } + }, + "required": true }, "responses": { "200": { - "description": "Password reset email was sent." + "description": "Password reset email was sent.", + "content": { + "application/json": { } + } }, "422": { "description": "Invalid reset password model." @@ -398,7 +1177,10 @@ ], "responses": { "200": { - "description": "Password reset email was cancelled." + "description": "Password reset email was cancelled.", + "content": { + "application/json": { } + } }, "400": { "description": "Invalid password reset token." @@ -728,7 +1510,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -737,7 +1523,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -786,7 +1576,7 @@ "Event" ], "summary": "Submit event by POST", - "description": "You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\nwe will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\nobject into the events data collection.\n \nYou can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\n \nSimple event:\n```\n{ \"message\": \"Exceptionless is amazing!\" }\n```\n \nSimple log event with user identity:\n```\n{\n \"type\": \"log\",\n \"message\": \"Exceptionless is amazing!\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\n}\n```\n \nMultiple events from string content:\n```\nExceptionless is amazing!\nExceptionless is really amazing!\n```\n \nSimple error:\n```\n{\n \"type\": \"error\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@simple_error\": {\n \"message\": \"Simple Exception\",\n \"type\": \"System.Exception\",\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\n }\n}\n```", + "description": " You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\n we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\n object into the events data collection.\n\n You can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\n\n Simple event:\n ```{ \"message\": \"Exceptionless is amazing!\" }```\n\n Simple log event with user identity:\n ```{\n \"type\": \"log\",\n \"message\": \"Exceptionless is amazing!\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\n}```\n\n Multiple events from string content:\n ```Exceptionless is amazing!\nExceptionless is really amazing!```\n\n Simple error:\n ```{\n \"type\": \"error\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@simple_error\": {\n \"message\": \"Simple Exception\",\n \"type\": \"System.Exception\",\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\n }\n}```", "parameters": [ { "name": "userAgent", @@ -816,7 +1606,10 @@ }, "responses": { "202": { - "description": "Accepted" + "description": "Accepted", + "content": { + "application/json": { } + } }, "400": { "description": "No project id specified and no default project was found." @@ -889,7 +1682,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -898,7 +1695,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -1008,7 +1809,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -1017,7 +1822,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -1069,7 +1878,7 @@ "Event" ], "summary": "Submit event by POST for a specific project", - "description": "You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\nwe will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\nobject into the events data collection.\n \nYou can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\n \nSimple event:\n```\n{ \"message\": \"Exceptionless is amazing!\" }\n```\n \nSimple log event with user identity:\n```\n{\n \"type\": \"log\",\n \"message\": \"Exceptionless is amazing!\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\n}\n```\n \nMultiple events from string content:\n```\nExceptionless is amazing!\nExceptionless is really amazing!\n```\n \nSimple error:\n```\n{\n \"type\": \"error\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@simple_error\": {\n \"message\": \"Simple Exception\",\n \"type\": \"System.Exception\",\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\n }\n}\n```", + "description": " You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\n we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\n object into the events data collection.\n\n You can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\n\n Simple event:\n ```{ \"message\": \"Exceptionless is amazing!\" }```\n\n Simple log event with user identity:\n ```{\n \"type\": \"log\",\n \"message\": \"Exceptionless is amazing!\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\n}```\n\n Multiple events from string content:\n ```Exceptionless is amazing!\nExceptionless is really amazing!```\n\n Simple error:\n ```{\n \"type\": \"error\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@simple_error\": {\n \"message\": \"Simple Exception\",\n \"type\": \"System.Exception\",\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\n }\n}```", "parameters": [ { "name": "projectId", @@ -1109,7 +1918,10 @@ }, "responses": { "202": { - "description": "Accepted" + "description": "Accepted", + "content": { + "application/json": { } + } }, "400": { "description": "No project id specified and no default project was found." @@ -1182,7 +1994,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -1191,7 +2007,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -1277,7 +2097,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -1286,7 +2110,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -1379,7 +2207,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -1388,7 +2220,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -1498,7 +2334,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -1507,7 +2347,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -1624,7 +2468,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -1633,7 +2481,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -1733,7 +2585,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -1742,7 +2598,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -1846,7 +2706,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -1855,7 +2719,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -1965,7 +2833,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -1974,7 +2846,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -1988,11 +2864,255 @@ } }, { - "name": "after", + "name": "after", + "in": "query", + "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistentEvent" + } + } + } + } + }, + "400": { + "description": "Invalid filter." + }, + "404": { + "description": "The project could not be found." + }, + "426": { + "description": "Unable to view event occurrences for the suspended organization." + } + } + } + }, + "/api/v2/events/by-ref/{referenceId}/user-description": { + "post": { + "tags": [ + "Event" + ], + "summary": "Set user description", + "description": "You can also save an end users contact information and a description of the event. This is really useful for error events as a user can specify reproduction steps in the description.", + "parameters": [ + { + "name": "referenceId", + "in": "path", + "description": "An identifier used that references an event instance.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d-]{8,100}$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The identifier of the project.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDescription" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UserDescription" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { } + } + }, + "400": { + "description": "Description must be specified." + }, + "404": { + "description": "The event occurrence with the specified reference id could not be found." + } + } + } + }, + "/api/v2/projects/{projectId}/events/by-ref/{referenceId}/user-description": { + "post": { + "tags": [ + "Event" + ], + "summary": "Set user description", + "description": "You can also save an end users contact information and a description of the event. This is really useful for error events as a user can specify reproduction steps in the description.", + "parameters": [ + { + "name": "referenceId", + "in": "path", + "description": "An identifier used that references an event instance.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d-]{8,100}$", + "type": "string" + } + }, + { + "name": "projectId", + "in": "path", + "description": "The identifier of the project.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The user description.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDescription" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UserDescription" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { } + } + }, + "400": { + "description": "Description must be specified." + }, + "404": { + "description": "The event occurrence with the specified reference id could not be found." + } + } + } + }, + "/api/v1/error/{id}": { + "patch": { + "tags": [ + "Event" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEvent" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateEvent" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { } + } + } + } + } + }, + "/api/v2/events/session/heartbeat": { + "get": { + "tags": [ + "Event" + ], + "summary": "Submit heartbeat", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "The session id or user id.", + "schema": { + "type": "string" + } + }, + { + "name": "close", + "in": "query", + "description": "If true, the session will be closed.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { } + } + }, + "400": { + "description": "No project id specified and no default project was found." + }, + "404": { + "description": "No project was found." + } + } + } + }, + "/api/v1/events/submit": { + "get": { + "tags": [ + "Event" + ], + "parameters": [ + { + "name": "userAgent", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "parameters", "in": "query", - "description": "The after parameter is a cursor used for pagination and defines your place in the list of results.", "schema": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/components/schemas/KeyValuePairOfstringAndStringValues" + } } } ], @@ -2000,156 +3120,146 @@ "200": { "description": "OK", "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PersistentEvent" - } - } - } + "application/json": { } } - }, - "400": { - "description": "Invalid filter." - }, - "404": { - "description": "The project could not be found." - }, - "426": { - "description": "Unable to view event occurrences for the suspended organization." } } } }, - "/api/v2/events/by-ref/{referenceId}/user-description": { - "post": { + "/api/v1/events/submit/{type}": { + "get": { "tags": [ "Event" ], - "summary": "Set user description", - "description": "You can also save an end users contact information and a description of the event. This is really useful for error events as a user can specify reproduction steps in the description.", "parameters": [ { - "name": "referenceId", + "name": "type", "in": "path", - "description": "An identifier used that references an event instance.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d-]{8,100}$", + "minLength": 1, "type": "string" } - } - ], - "requestBody": { - "description": "The user description.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserDescription" + }, + { + "name": "userAgent", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "parameters", + "in": "query", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KeyValuePairOfstringAndStringValues" } } } - }, + ], "responses": { - "202": { - "description": "Accepted" - }, - "400": { - "description": "Description must be specified." - }, - "404": { - "description": "The event occurrence with the specified reference id could not be found." + "200": { + "description": "OK", + "content": { + "application/json": { } + } } } } }, - "/api/v2/projects/{projectId}/events/by-ref/{referenceId}/user-description": { - "post": { + "/api/v1/projects/{projectId}/events/submit": { + "get": { "tags": [ "Event" ], - "summary": "Set user description", - "description": "You can also save an end users contact information and a description of the event. This is really useful for error events as a user can specify reproduction steps in the description.", "parameters": [ { - "name": "referenceId", + "name": "projectId", "in": "path", - "description": "An identifier used that references an event instance.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d-]{8,100}$", + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "projectId", - "in": "path", - "description": "The identifier of the project.", - "required": true, + "name": "userAgent", + "in": "header", "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } - } - ], - "requestBody": { - "description": "The user description.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserDescription" + }, + { + "name": "parameters", + "in": "query", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KeyValuePairOfstringAndStringValues" } } } - }, + ], "responses": { - "202": { - "description": "Accepted" - }, - "400": { - "description": "Description must be specified." - }, - "404": { - "description": "The event occurrence with the specified reference id could not be found." + "200": { + "description": "OK", + "content": { + "application/json": { } + } } } } }, - "/api/v2/events/session/heartbeat": { + "/api/v1/projects/{projectId}/events/submit/{type}": { "get": { "tags": [ "Event" ], - "summary": "Submit heartbeat", "parameters": [ { - "name": "id", - "in": "query", - "description": "The session id or user id.", + "name": "projectId", + "in": "path", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { - "name": "close", + "name": "type", + "in": "path", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "parameters", "in": "query", - "description": "If true, the session will be closed.", "schema": { - "type": "boolean", - "default": false + "type": "array", + "items": { + "$ref": "#/components/schemas/KeyValuePairOfstringAndStringValues" + } } } ], "responses": { "200": { - "description": "OK" - }, - "400": { - "description": "No project id specified and no default project was found." - }, - "404": { - "description": "No project was found." + "description": "OK", + "content": { + "application/json": { } + } } } } @@ -2160,7 +3270,7 @@ "Event" ], "summary": "Submit event by GET", - "description": "You can submit an event using an HTTP GET and query string parameters. Any unknown query string parameters will be added to the extended data of the event.\n \nFeature usage named build with a duration of 10:\n```/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\n \nLog with message, geo and extended data\n```/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "description": "You can submit an event using an HTTP GET and query string parameters. Any unknown query string parameters will be added to the extended data of the event.\n\nFeature usage named build with a duration of 10:\n```/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\n\nLog with message, geo and extended data\n```/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", "parameters": [ { "name": "type", @@ -2207,7 +3317,11 @@ "in": "query", "description": "The number of duplicated events.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -2216,7 +3330,11 @@ "in": "query", "description": "The value of the event if any.", "schema": { - "type": "number", + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], "format": "double" } }, @@ -2267,14 +3385,17 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" + "$ref": "#/components/schemas/KeyValuePairOfstringAndStringValues" } } } ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "400": { "description": "No project id specified and no default project was found." @@ -2291,7 +3412,7 @@ "Event" ], "summary": "Submit event type by GET", - "description": "You can submit an event using an HTTP GET and query string parameters.\n \nFeature usage event named build with a value of 10:\n```/events/submit/usage?access_token=YOUR_API_KEY&source=build&value=10```\n \nLog event with message, geo and extended data\n```/events/submit/log?access_token=YOUR_API_KEY&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage event named build with a value of 10:\n```/events/submit/usage?access_token=YOUR_API_KEY&source=build&value=10```\n\nLog event with message, geo and extended data\n```/events/submit/log?access_token=YOUR_API_KEY&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", "parameters": [ { "name": "type", @@ -2340,7 +3461,11 @@ "in": "query", "description": "The number of duplicated events.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -2349,7 +3474,11 @@ "in": "query", "description": "The value of the event if any.", "schema": { - "type": "number", + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], "format": "double" } }, @@ -2400,14 +3529,17 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" + "$ref": "#/components/schemas/KeyValuePairOfstringAndStringValues" } } } ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "400": { "description": "No project id specified and no default project was found." @@ -2424,7 +3556,7 @@ "Event" ], "summary": "Submit event type by GET for a specific project", - "description": "You can submit an event using an HTTP GET and query string parameters.\n \nFeature usage named build with a duration of 10:\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\n \nLog with message, geo and extended data\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage named build with a duration of 10:\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\n\nLog with message, geo and extended data\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", "parameters": [ { "name": "projectId", @@ -2473,7 +3605,11 @@ "in": "query", "description": "The number of duplicated events.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -2482,7 +3618,11 @@ "in": "query", "description": "The value of the event if any.", "schema": { - "type": "number", + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], "format": "double" } }, @@ -2533,14 +3673,17 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" + "$ref": "#/components/schemas/KeyValuePairOfstringAndStringValues" } } } ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "400": { "description": "No project id specified and no default project was found." @@ -2557,7 +3700,7 @@ "Event" ], "summary": "Submit event type by GET for a specific project", - "description": "You can submit an event using an HTTP GET and query string parameters.\n \nFeature usage named build with a duration of 10:\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\n \nLog with message, geo and extended data\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage named build with a duration of 10:\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\n\nLog with message, geo and extended data\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", "parameters": [ { "name": "projectId", @@ -2616,7 +3759,11 @@ "in": "query", "description": "The number of duplicated events.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } }, @@ -2625,7 +3772,11 @@ "in": "query", "description": "The value of the event if any.", "schema": { - "type": "number", + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], "format": "double" } }, @@ -2637,59 +3788,194 @@ "type": "string" } }, - { - "name": "tags", - "in": "query", - "description": "A list of tags used to categorize this event (comma separated).", - "schema": { - "type": "string" + { + "name": "tags", + "in": "query", + "description": "A list of tags used to categorize this event (comma separated).", + "schema": { + "type": "string" + } + }, + { + "name": "identity", + "in": "query", + "description": "The user's identity that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "identityname", + "in": "query", + "description": "The user's friendly name that the event happened to.", + "schema": { + "type": "string" + } + }, + { + "name": "userAgent", + "in": "header", + "description": "The user agent that submitted the event.", + "schema": { + "type": "string" + } + }, + { + "name": "parameters", + "in": "query", + "description": "Query String parameters that control what properties are set on the event", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KeyValuePairOfstringAndStringValues" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { } + } + }, + "400": { + "description": "No project id specified and no default project was found." + }, + "404": { + "description": "No project was found." + } + } + } + }, + "/api/v1/error": { + "post": { + "tags": [ + "Event" + ], + "parameters": [ + { + "name": "userAgent", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "string", + "example": "" + } + }, + "text/plain": { + "schema": { + "type": "string", + "example": "" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { } } - }, + } + } + } + }, + "/api/v1/events": { + "post": { + "tags": [ + "Event" + ], + "parameters": [ { - "name": "identity", - "in": "query", - "description": "The user's identity that the event happened to.", + "name": "userAgent", + "in": "header", "schema": { "type": "string" } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "string", + "example": "" + } + }, + "text/plain": { + "schema": { + "type": "string", + "example": "" + } + } }, + "required": true + }, + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { } + } + } + } + } + }, + "/api/v1/projects/{projectId}/events": { + "post": { + "tags": [ + "Event" + ], + "parameters": [ { - "name": "identityname", - "in": "query", - "description": "The user's friendly name that the event happened to.", + "name": "projectId", + "in": "path", + "required": true, "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } }, { "name": "userAgent", "in": "header", - "description": "The user agent that submitted the event.", "schema": { "type": "string" } - }, - { - "name": "parameters", - "in": "query", - "description": "Query String parameters that control what properties are set on the event", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StringStringValuesKeyValuePair" - } - } } ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "No project id specified and no default project was found." + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "string", + "example": "" + } + }, + "text/plain": { + "schema": { + "type": "string", + "example": "" + } + } }, - "404": { - "description": "No project was found." + "required": true + }, + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { } + } } } } @@ -2779,8 +4065,14 @@ "schema": { "$ref": "#/components/schemas/NewOrganization" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/NewOrganization" + } } - } + }, + "required": true }, "responses": { "201": { @@ -2869,8 +4161,14 @@ "schema": { "$ref": "#/components/schemas/NewOrganization" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/NewOrganization" + } } - } + }, + "required": true }, "responses": { "200": { @@ -2915,8 +4213,14 @@ "schema": { "$ref": "#/components/schemas/NewOrganization" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/NewOrganization" + } } - } + }, + "required": true }, "responses": { "200": { @@ -3052,7 +4356,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 12 } @@ -3261,7 +4569,10 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "400": { "description": "The error occurred while removing the user from your organization" @@ -3305,14 +4616,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + "$ref": "#/components/schemas/ValueFromBodyOfstring" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ValueFromBodyOfstring" } } - } + }, + "required": true }, "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "404": { "description": "The organization was not found." @@ -3348,7 +4668,10 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "404": { "description": "The organization was not found." @@ -3374,7 +4697,10 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "201": { "description": "The organization name is available." @@ -3413,7 +4739,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 1 } @@ -3423,7 +4753,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -3465,8 +4799,14 @@ "schema": { "$ref": "#/components/schemas/NewProject" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/NewProject" + } } - } + }, + "required": true }, "responses": { "201": { @@ -3526,7 +4866,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 1 } @@ -3536,7 +4880,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -3637,8 +4985,14 @@ "schema": { "$ref": "#/components/schemas/UpdateProject" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateProject" + } } - } + }, + "required": true }, "responses": { "200": { @@ -3683,8 +5037,14 @@ "schema": { "$ref": "#/components/schemas/UpdateProject" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateProject" + } } - } + }, + "required": true }, "responses": { "200": { @@ -3747,6 +5107,39 @@ } } }, + "/api/v1/project/config": { + "get": { + "tags": [ + "Project" + ], + "parameters": [ + { + "name": "v", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientConfiguration" + } + } + } + } + } + } + }, "/api/v2/projects/config": { "get": { "tags": [ @@ -3759,7 +5152,11 @@ "in": "query", "description": "The client configuration version.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } } @@ -3806,7 +5203,11 @@ "in": "query", "description": "The client configuration version.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } } @@ -3860,14 +5261,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + "$ref": "#/components/schemas/ValueFromBodyOfstring" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ValueFromBodyOfstring" } } - } + }, + "required": true }, "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "400": { "description": "Invalid configuration value." @@ -3904,7 +5314,10 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "400": { "description": "Invalid key value." @@ -4028,12 +5441,21 @@ "schema": { "$ref": "#/components/schemas/NotificationSettings" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/NotificationSettings" + } } - } + }, + "required": true }, "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "404": { "description": "The project could not be found." @@ -4074,12 +5496,21 @@ "schema": { "$ref": "#/components/schemas/NotificationSettings" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/NotificationSettings" + } } - } + }, + "required": true }, "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "404": { "description": "The project could not be found." @@ -4115,7 +5546,10 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "404": { "description": "The project could not be found." @@ -4156,14 +5590,36 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationSettings" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] } } } }, "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "404": { "description": "The project or integration could not be found." @@ -4205,14 +5661,36 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationSettings" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] } } } }, "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "404": { "description": "The project or integration could not be found." @@ -4251,7 +5729,10 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "400": { "description": "Invalid tab name." @@ -4288,7 +5769,10 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "400": { "description": "Invalid tab name." @@ -4325,7 +5809,10 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "400": { "description": "Invalid tab name." @@ -4354,7 +5841,10 @@ ], "responses": { "201": { - "description": "The project name is available." + "description": "The project name is available.", + "content": { + "application/json": { } + } }, "204": { "description": "The project name is not available." @@ -4390,7 +5880,10 @@ ], "responses": { "201": { - "description": "The project name is available." + "description": "The project name is available.", + "content": { + "application/json": { } + } }, "204": { "description": "The project name is not available." @@ -4429,14 +5922,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + "$ref": "#/components/schemas/ValueFromBodyOfstring" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ValueFromBodyOfstring" } } - } + }, + "required": true }, "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "400": { "description": "Invalid key or value." @@ -4473,7 +5975,10 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "400": { "description": "Invalid key or value." @@ -4556,7 +6061,10 @@ ], "responses": { "202": { - "description": "Accepted" + "description": "Accepted", + "content": { + "application/json": { } + } }, "404": { "description": "One or more stacks could not be found." @@ -4593,7 +6101,10 @@ ], "responses": { "202": { - "description": "Accepted" + "description": "Accepted", + "content": { + "application/json": { } + } }, "404": { "description": "One or more stacks could not be found." @@ -4624,14 +6135,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + "$ref": "#/components/schemas/ValueFromBodyOfstring" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ValueFromBodyOfstring" } } - } + }, + "required": true }, "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "400": { "description": "Invalid reference link." @@ -4665,14 +6185,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StringValueFromBody" + "$ref": "#/components/schemas/ValueFromBodyOfstring" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ValueFromBodyOfstring" } } - } + }, + "required": true }, "responses": { "204": { - "description": "The reference link was removed." + "description": "The reference link was removed.", + "content": { + "application/json": { } + } }, "400": { "description": "Invalid reference link." @@ -4703,7 +6232,10 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "404": { "description": "One or more stacks could not be found." @@ -4729,7 +6261,10 @@ ], "responses": { "204": { - "description": "The stacks were marked as not critical." + "description": "The stacks were marked as not critical.", + "content": { + "application/json": { } + } }, "404": { "description": "One or more stacks could not be found." @@ -4765,7 +6300,10 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "404": { "description": "One or more stacks could not be found." @@ -4793,7 +6331,10 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { } + } }, "404": { "description": "The stack could not be found." @@ -4900,7 +6441,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 1 } @@ -4910,7 +6455,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -4998,7 +6547,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 1 } @@ -5008,7 +6561,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -5102,7 +6659,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 1 } @@ -5112,7 +6673,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -5701,8 +7266,14 @@ "schema": { "$ref": "#/components/schemas/UpdateUser" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateUser" + } } - } + }, + "required": true }, "responses": { "200": { @@ -5747,8 +7318,14 @@ "schema": { "$ref": "#/components/schemas/UpdateUser" } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateUser" + } } - } + }, + "required": true }, "responses": { "200": { @@ -5792,7 +7369,11 @@ "in": "query", "description": "The page parameter is used for pagination. This value must be greater than 0.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 1 } @@ -5802,7 +7383,11 @@ "in": "query", "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", "schema": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32", "default": 10 } @@ -5849,211 +7434,52 @@ "responses": { "202": { "description": "Accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" - } - } - } - }, - "400": { - "description": "One or more validation errors occurred." - }, - "404": { - "description": "One or more users were not found." - }, - "500": { - "description": "An error occurred while deleting one or more users." - } - } - } - }, - "/api/v2/users/{id}/email-address/{email}": { - "post": { - "tags": [ - "User" - ], - "summary": "Update email address", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The identifier of the user.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" - } - }, - { - "name": "email", - "in": "path", - "description": "The new email address.", - "required": true, - "schema": { - "minLength": 1, - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateEmailAddressResult" - } - } - } - }, - "400": { - "description": "An error occurred while updating the users email address." - }, - "422": { - "description": "Validation error" - }, - "429": { - "description": "Update email address rate limit reached." - } - } - } - }, - "/api/v2/users/verify-email-address/{token}": { - "get": { - "tags": [ - "User" - ], - "summary": "Verify email address", - "parameters": [ - { - "name": "token", - "in": "path", - "description": "The token identifier.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d-]{24,40}$", - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "The user could not be found." - }, - "422": { - "description": "Verify Email Address Token has expired." - } - } - } - }, - "/api/v2/users/{id}/resend-verification-email": { - "get": { - "tags": [ - "User" - ], - "summary": "Resend verification email", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The identifier of the user.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "The user verification email has been sent." - }, - "404": { - "description": "The user could not be found." - } - } - } - }, - "/api/v2/projects/{projectId}/webhooks": { - "get": { - "tags": [ - "WebHook" - ], - "summary": "Get by project", - "parameters": [ - { - "name": "projectId", - "in": "path", - "description": "The identifier of the project.", - "required": true, - "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}$", - "type": "string" - } - }, - { - "name": "page", - "in": "query", - "description": "The page parameter is used for pagination. This value must be greater than 0.", - "schema": { - "type": "integer", - "format": "int32", - "default": 1 - } - }, - { - "name": "limit", - "in": "query", - "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", - "schema": { - "type": "integer", - "format": "int32", - "default": 10 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WebHook" - } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" } } } }, + "400": { + "description": "One or more validation errors occurred." + }, "404": { - "description": "The project could not be found." + "description": "One or more users were not found." + }, + "500": { + "description": "An error occurred while deleting one or more users." } } } }, - "/api/v2/webhooks/{id}": { - "get": { + "/api/v2/users/{id}/email-address/{email}": { + "post": { "tags": [ - "WebHook" + "User" ], - "summary": "Get by id", - "operationId": "GetWebHookById", + "summary": "Update email address", "parameters": [ { "name": "id", "in": "path", - "description": "The identifier of the web hook.", + "description": "The identifier of the user.", "required": true, "schema": { "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } + }, + { + "name": "email", + "in": "path", + "description": "The new email address.", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } } ], "responses": { @@ -6062,90 +7488,84 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebHook" + "$ref": "#/components/schemas/UpdateEmailAddressResult" } } } }, - "404": { - "description": "The web hook could not be found." + "400": { + "description": "An error occurred while updating the users email address." + }, + "422": { + "description": "Validation error" + }, + "429": { + "description": "Update email address rate limit reached." } } } }, - "/api/v2/webhooks": { - "post": { + "/api/v2/users/verify-email-address/{token}": { + "get": { "tags": [ - "WebHook" + "User" ], - "summary": "Create", - "requestBody": { - "description": "The web hook.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWebHook" - } + "summary": "Verify email address", + "parameters": [ + { + "name": "token", + "in": "path", + "description": "The token identifier.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d-]{24,40}$", + "type": "string" } } - }, + ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebHook" - } - } + "application/json": { } } }, - "400": { - "description": "An error occurred while creating the web hook." + "404": { + "description": "The user could not be found." }, - "409": { - "description": "The web hook already exists." + "422": { + "description": "Verify Email Address Token has expired." } } } }, - "/api/v2/webhooks/{ids}": { - "delete": { + "/api/v2/users/{id}/resend-verification-email": { + "get": { "tags": [ - "WebHook" + "User" ], - "summary": "Remove", + "summary": "Resend verification email", "parameters": [ { - "name": "ids", + "name": "id", "in": "path", - "description": "A comma-delimited list of web hook identifiers.", + "description": "The identifier of the user.", "required": true, "schema": { - "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "pattern": "^[a-zA-Z\\d]{24,36}$", "type": "string" } } ], "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "The user verification email has been sent.", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkInProgressResult" - } - } + "application/json": { } } }, - "400": { - "description": "One or more validation errors occurred." - }, "404": { - "description": "One or more web hooks were not found." - }, - "500": { - "description": "An error occurred while deleting one or more web hooks." + "description": "The user could not be found." } } } @@ -6155,9 +7575,9 @@ "schemas": { "BillingPlan": { "required": [ - "description", "id", - "name" + "name", + "description" ], "type": "object", "properties": { @@ -6171,23 +7591,43 @@ "type": "string" }, "price": { - "type": "number", + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], "format": "double" }, "max_projects": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "max_users": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "retention_days": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "max_events_per_month": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "has_premium_features": { @@ -6196,26 +7636,10 @@ "is_hidden": { "type": "boolean" } - }, - "additionalProperties": false + } }, "BillingStatus": { - "enum": [ - 0, - 1, - 2, - 3, - 4 - ], - "type": "integer", - "format": "int32", - "x-enumNames": [ - "Trialing", - "Active", - "PastDue", - "Canceled", - "Unpaid" - ] + "type": "integer" }, "ChangePasswordModel": { "required": [ @@ -6234,8 +7658,7 @@ "minLength": 6, "type": "string" } - }, - "additionalProperties": false + } }, "ChangePlanResult": { "type": "object", @@ -6244,52 +7667,57 @@ "type": "boolean" }, "message": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] } - }, - "additionalProperties": false + } }, "ClientConfiguration": { "type": "object", "properties": { "version": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "settings": { "type": "object", "additionalProperties": { "type": "string" - }, - "readOnly": true + } } - }, - "additionalProperties": false + } }, "CountResult": { "type": "object", "properties": { "total": { - "type": "integer", - "format": "int64" + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64", + "default": 0 }, "aggregations": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/IAggregate" - }, - "nullable": true + "type": [ + "null", + "object" + ] }, "data": { - "type": "object", - "additionalProperties": { - "nullable": true - }, - "nullable": true + "type": [ + "null", + "object" + ] } - }, - "additionalProperties": false + } }, "ExternalAuthInfo": { "required": [ @@ -6312,30 +7740,18 @@ "type": "string" }, "inviteToken": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "IAggregate": { - "type": "object", - "properties": { - "data": { - "type": "object", - "additionalProperties": { - "nullable": true - }, - "nullable": true + "type": [ + "null", + "string" + ] } - }, - "additionalProperties": false + } }, "Invite": { "required": [ - "date_added", + "token", "email_address", - "token" + "date_added" ], "type": "object", "properties": { @@ -6349,15 +7765,14 @@ "type": "string", "format": "date-time" } - }, - "additionalProperties": false + } }, "Invoice": { "required": [ - "date", "id", "organization_id", "organization_name", + "date", "paid", "total" ], @@ -6380,7 +7795,11 @@ "type": "boolean" }, "total": { - "type": "number", + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], "format": "double" }, "items": { @@ -6389,13 +7808,12 @@ "$ref": "#/components/schemas/InvoiceLineItem" } } - }, - "additionalProperties": false + } }, "InvoiceGridModel": { "required": [ - "date", "id", + "date", "paid" ], "type": "object", @@ -6410,13 +7828,12 @@ "paid": { "type": "boolean" } - }, - "additionalProperties": false + } }, "InvoiceLineItem": { "required": [ - "amount", - "description" + "description", + "amount" ], "type": "object", "properties": { @@ -6424,15 +7841,41 @@ "type": "string" }, "date": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "amount": { - "type": "number", + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], "format": "double" } - }, - "additionalProperties": false + } + }, + "KeyValuePairOfstringAndStringValues": { + "required": [ + "key", + "value" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } }, "Login": { "required": [ @@ -6442,7 +7885,6 @@ "type": "object", "properties": { "email": { - "minLength": 1, "type": "string", "description": "The email address or domain username" }, @@ -6454,11 +7896,12 @@ "invite_token": { "maxLength": 40, "minLength": 40, - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] } - }, - "additionalProperties": false + } }, "NewOrganization": { "type": "object", @@ -6467,7 +7910,7 @@ "type": "string" } }, - "additionalProperties": false + "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." }, "NewProject": { "type": "object", @@ -6481,8 +7924,7 @@ "delete_bot_data_enabled": { "type": "boolean" } - }, - "additionalProperties": false + } }, "NewToken": { "type": "object", @@ -6494,8 +7936,10 @@ "type": "string" }, "default_project_id": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "scopes": { "uniqueItems": true, @@ -6505,16 +7949,19 @@ } }, "expires_utc": { - "type": "string", - "format": "date-time", - "nullable": true + "type": [ + "null", + "string" + ], + "format": "date-time" }, "notes": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] } - }, - "additionalProperties": false + } }, "NewWebHook": { "type": "object", @@ -6535,12 +7982,14 @@ } }, "version": { - "type": "string", - "description": "The schema version that should be used.", - "nullable": true + "pattern": "^\\d+(\\.\\d+){1,3}$", + "type": [ + "null", + "string" + ], + "description": "The schema version that should be used." } - }, - "additionalProperties": false + } }, "NotificationSettings": { "type": "object", @@ -6563,8 +8012,7 @@ "report_critical_events": { "type": "boolean" } - }, - "additionalProperties": false + } }, "OAuthAccount": { "required": [ @@ -6584,95 +8032,133 @@ "type": "string" }, "extra_data": { - "type": "object", + "type": [ + "null", + "object" + ], "additionalProperties": { "type": "string" }, "readOnly": true } - }, - "additionalProperties": false + } }, "PersistentEvent": { "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "description": "Unique id that identifies an event." }, "organization_id": { - "type": "string" + "type": "string", + "description": "The organization that the event belongs to." }, "project_id": { - "type": "string" + "type": "string", + "description": "The project that the event belongs to." }, "stack_id": { - "type": "string" + "type": "string", + "description": "The stack that the event belongs to." }, "is_first_occurrence": { - "type": "boolean" + "type": "boolean", + "description": "Whether the event resulted in the creation of a new stack." }, "created_utc": { "type": "string", + "description": "The date that the event was created in the system.", "format": "date-time" }, "idx": { "type": "object", - "additionalProperties": { } + "description": "Used to store primitive data type custom data values for searching the event." }, "type": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ], + "description": "The event type (ie. error, log message, feature usage). Check KnownTypes for standard event types." }, "source": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ], + "description": "The event source (ie. machine name, log name, feature name)." }, "date": { "type": "string", + "description": "The date that the event occurred on.", "format": "date-time" }, "tags": { "uniqueItems": true, - "type": "array", + "type": [ + "null", + "array" + ], "items": { "type": "string" }, - "nullable": true + "description": "A list of tags used to categorize this event." }, - "message": { - "type": "string", - "nullable": true + "message": { + "type": [ + "null", + "string" + ], + "description": "The event message." }, "geo": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ], + "description": "The geo coordinates where the event happened." }, "value": { - "type": "number", - "format": "double", - "nullable": true + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "description": "The value of the event if any.", + "format": "double" }, "count": { - "type": "integer", - "format": "int32", - "nullable": true + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "description": "The number of duplicated events.", + "format": "int32" }, "data": { - "type": "object", - "additionalProperties": { }, - "nullable": true + "type": [ + "null", + "object" + ], + "description": "Optional data entries that contain additional information about this event." }, "reference_id": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ], + "description": "An optional identifier to be used for referencing this event instance at a later time." } - }, - "additionalProperties": false + } }, "ResetPasswordModel": { "required": [ - "password", - "password_reset_token" + "password_reset_token", + "password" ], "type": "object", "properties": { @@ -6686,23 +8172,20 @@ "minLength": 6, "type": "string" } - }, - "additionalProperties": false + } }, "Signup": { "required": [ - "email", "name", + "email", "password" ], "type": "object", "properties": { "name": { - "minLength": 1, "type": "string" }, "email": { - "minLength": 1, "type": "string", "description": "The email address or domain username" }, @@ -6714,90 +8197,122 @@ "invite_token": { "maxLength": 40, "minLength": 40, - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] } - }, - "additionalProperties": false + } }, "Stack": { "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "description": "Unique id that identifies a stack." }, "organization_id": { - "type": "string" + "type": "string", + "description": "The organization that the stack belongs to." }, "project_id": { - "type": "string" + "type": "string", + "description": "The project that the stack belongs to." }, "type": { - "type": "string" + "type": "string", + "description": "The stack type (ie. error, log message, feature usage). Check KnownTypes for standard stack types." }, "status": { + "description": "The stack status (ie. open, fixed, regressed,", "$ref": "#/components/schemas/StackStatus" }, "snooze_until_utc": { - "type": "string", - "format": "date-time", - "nullable": true + "type": [ + "null", + "string" + ], + "description": "The date that the stack should be snoozed until.", + "format": "date-time" }, "signature_hash": { - "type": "string" + "type": "string", + "description": "The signature used for stacking future occurrences." }, "signature_info": { "type": "object", "additionalProperties": { "type": "string" - } + }, + "description": "The collection of information that went into creating the signature hash for the stack." }, "fixed_in_version": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ], + "description": "The version the stack was fixed in." }, "date_fixed": { - "type": "string", - "format": "date-time", - "nullable": true + "type": [ + "null", + "string" + ], + "description": "The date the stack was fixed.", + "format": "date-time" }, "title": { - "type": "string" + "type": "string", + "description": "The stack title." }, "total_occurrences": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "description": "The total number of occurrences in the stack.", "format": "int32" }, "first_occurrence": { "type": "string", + "description": "The date of the 1st occurrence of this stack in UTC time.", "format": "date-time" }, "last_occurrence": { "type": "string", + "description": "The date of the last occurrence of this stack in UTC time.", "format": "date-time" }, "description": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ], + "description": "The stack description." }, "occurrences_are_critical": { - "type": "boolean" + "type": "boolean", + "description": "If true, all future occurrences will be marked as critical." }, "references": { "type": "array", "items": { "type": "string" - } + }, + "description": "A list of references." }, "tags": { "uniqueItems": true, "type": "array", "items": { "type": "string" - } + }, + "description": "A list of tags used to categorize this stack." }, "duplicate_signature": { - "type": "string" + "type": "string", + "description": "The signature used for finding duplicate stacks. (ProjectId, SignatureHash)" }, "created_utc": { "type": "string", @@ -6814,8 +8329,7 @@ "type": "boolean", "readOnly": true } - }, - "additionalProperties": false + } }, "StackStatus": { "enum": [ @@ -6826,7 +8340,6 @@ "ignored", "discarded" ], - "type": "string", "x-enumNames": [ "Open", "Fixed", @@ -6836,56 +8349,75 @@ "Discarded" ] }, - "StringStringValuesKeyValuePair": { + "TokenResult": { + "required": [ + "token" + ], "type": "object", "properties": { - "key": { - "type": "string", - "nullable": true + "token": { + "type": "string" + } + } + }, + "UpdateEmailAddressResult": { + "required": [ + "is_verified" + ], + "type": "object", + "properties": { + "is_verified": { + "type": "boolean" + } + } + }, + "UpdateEvent": { + "type": "object", + "properties": { + "email_address": { + "type": "string" }, - "value": { - "type": "array", - "items": { - "type": "string" - } + "description": { + "type": "string" } }, - "additionalProperties": false + "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." }, - "StringValueFromBody": { + "UpdateProject": { "type": "object", "properties": { - "value": { - "type": "string", - "nullable": true + "name": { + "type": "string" + }, + "delete_bot_data_enabled": { + "type": "boolean" } }, - "additionalProperties": false + "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." }, - "TokenResult": { - "required": [ - "token" - ], + "UpdateToken": { "type": "object", "properties": { - "token": { - "minLength": 1, + "is_disabled": { + "type": "boolean" + }, + "notes": { "type": "string" } }, - "additionalProperties": false + "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." }, - "UpdateEmailAddressResult": { - "required": [ - "is_verified" - ], + "UpdateUser": { "type": "object", "properties": { - "is_verified": { + "full_name": { + "type": "string" + }, + "email_notifications_enabled": { "type": "boolean" } }, - "additionalProperties": false + "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." }, "UpdateProject": { "type": "object", @@ -6936,23 +8468,38 @@ "format": "date-time" }, "total": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "blocked": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "discarded": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "too_big": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } - }, - "additionalProperties": false + } }, "UsageInfo": { "type": "object", @@ -6962,77 +8509,108 @@ "format": "date-time" }, "limit": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "total": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "blocked": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "discarded": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "too_big": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" } - }, - "additionalProperties": false + } }, "User": { "required": [ - "email_address", - "full_name" + "full_name", + "email_address" ], "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "description": "Unique id that identifies an user." }, "organization_ids": { "uniqueItems": true, - "type": "array", + "type": [ + "null", + "array" + ], "items": { "type": "string" }, + "description": "The organizations that the user has access to.", "readOnly": true }, "password": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "salt": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "password_reset_token": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "password_reset_token_expiration": { "type": "string", "format": "date-time" }, "o_auth_accounts": { - "type": "array", + "type": [ + "null", + "array" + ], "items": { "$ref": "#/components/schemas/OAuthAccount" }, "readOnly": true }, "full_name": { - "minLength": 1, - "type": "string" + "type": "string", + "description": "Gets or sets the users Full Name." }, "email_address": { - "minLength": 1, - "type": "string", - "format": "email" + "type": "string" }, "email_notifications_enabled": { "type": "boolean" @@ -7041,15 +8619,18 @@ "type": "boolean" }, "verify_email_address_token": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "verify_email_address_token_expiration": { "type": "string", "format": "date-time" }, "is_active": { - "type": "boolean" + "type": "boolean", + "description": "Gets or sets the users active state." }, "roles": { "uniqueItems": true, @@ -7066,34 +8647,54 @@ "type": "string", "format": "date-time" } - }, - "additionalProperties": false + } }, "UserDescription": { "type": "object", "properties": { "email_address": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "description": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "data": { - "type": "object", - "additionalProperties": { }, - "nullable": true + "type": [ + "null", + "object" + ], + "description": "Extended data entries for this user description." } - }, - "additionalProperties": false + } + }, + "ValueFromBodyOfstring": { + "required": [ + "value" + ], + "type": "object", + "properties": { + "value": { + "type": [ + "null", + "string" + ] + } + } }, "ViewCurrentUser": { "type": "object", "properties": { "hash": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "has_local_account": { "type": "boolean" @@ -7139,8 +8740,7 @@ "type": "string" } } - }, - "additionalProperties": false + } }, "ViewOrganization": { "type": "object", @@ -7169,84 +8769,136 @@ "type": "string" }, "card_last4": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "subscribe_date": { - "type": "string", - "format": "date-time", - "nullable": true + "type": [ + "null", + "string" + ], + "format": "date-time" }, "billing_change_date": { - "type": "string", - "format": "date-time", - "nullable": true + "type": [ + "null", + "string" + ], + "format": "date-time" }, "billing_changed_by_user_id": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "billing_status": { "$ref": "#/components/schemas/BillingStatus" }, "billing_price": { - "type": "number", + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], "format": "double" }, "max_events_per_month": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "bonus_events_per_month": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "bonus_expiration": { - "type": "string", - "format": "date-time", - "nullable": true + "type": [ + "null", + "string" + ], + "format": "date-time" }, "retention_days": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "is_suspended": { "type": "boolean" }, "suspension_code": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "suspension_notes": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "suspension_date": { - "type": "string", - "format": "date-time", - "nullable": true + "type": [ + "null", + "string" + ], + "format": "date-time" }, "has_premium_features": { "type": "boolean" }, "max_users": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "max_projects": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "project_count": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int64" }, "stack_count": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int64" }, "event_count": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int64" }, "invites": { @@ -7268,9 +8920,10 @@ } }, "data": { - "type": "object", - "additionalProperties": { }, - "nullable": true + "type": [ + "null", + "object" + ] }, "is_throttled": { "type": "boolean" @@ -7281,8 +8934,7 @@ "is_over_request_limit": { "type": "boolean" } - }, - "additionalProperties": false + } }, "ViewProject": { "type": "object", @@ -7307,9 +8959,10 @@ "type": "boolean" }, "data": { - "type": "object", - "additionalProperties": { }, - "nullable": true + "type": [ + "null", + "object" + ] }, "promoted_tabs": { "uniqueItems": true, @@ -7319,15 +8972,25 @@ } }, "is_configured": { - "type": "boolean", - "nullable": true + "type": [ + "null", + "boolean" + ] }, "stack_count": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int64" }, "event_count": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int64" }, "has_premium_features": { @@ -7348,8 +9011,7 @@ "$ref": "#/components/schemas/UsageInfo" } } - }, - "additionalProperties": false + } }, "ViewToken": { "type": "object", @@ -7364,12 +9026,16 @@ "type": "string" }, "user_id": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "default_project_id": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "scopes": { "uniqueItems": true, @@ -7379,13 +9045,17 @@ } }, "expires_utc": { - "type": "string", - "format": "date-time", - "nullable": true + "type": [ + "null", + "string" + ], + "format": "date-time" }, "notes": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "is_disabled": { "type": "boolean" @@ -7401,8 +9071,7 @@ "type": "string", "format": "date-time" } - }, - "additionalProperties": false + } }, "ViewUser": { "type": "object", @@ -7442,8 +9111,7 @@ "type": "string" } } - }, - "additionalProperties": false + } }, "WebHook": { "type": "object", @@ -7470,26 +9138,29 @@ "type": "boolean" }, "version": { - "type": "string" + "type": "string", + "description": "The schema version that should be used." }, "created_utc": { "type": "string", "format": "date-time" } - }, - "additionalProperties": false + } }, "WorkInProgressResult": { "type": "object", "properties": { "workers": { - "type": "array", + "type": [ + "null", + "array" + ], "items": { "type": "string" - } + }, + "readOnly": true } - }, - "additionalProperties": false + } } }, "securitySchemes": { @@ -7519,6 +9190,12 @@ } ], "tags": [ + { + "name": "Token" + }, + { + "name": "WebHook" + }, { "name": "Auth" }, @@ -7534,14 +9211,8 @@ { "name": "Stack" }, - { - "name": "Token" - }, { "name": "User" - }, - { - "name": "WebHook" } ] } \ No newline at end of file diff --git a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs index 25f0bd950b..1205debd51 100644 --- a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs @@ -2,6 +2,7 @@ using System.IO.Compression; using System.Net; using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; using System.Web; using Exceptionless.Core.Billing; @@ -65,9 +66,11 @@ protected override async Task ResetDataAsync() } [Fact] - public async Task CanPostUserDescriptionAsync() + public async Task PostEvent_WithValidPayload_EnqueuesAndProcessesEventAsync() { - const string json = "{\"message\":\"test\",\"reference_id\":\"TestReferenceId\",\"@user\":{\"identity\":\"Test user\",\"name\":null}}"; + var jsonOptions = GetService(); + /* language=json */ + const string json = """{"message":"test","reference_id":"TestReferenceId","@user":{"identity":"Test user","name":null}}"""; await SendRequestAsync(r => r .Post() .AsTestOrganizationClientUser() @@ -92,12 +95,12 @@ await SendRequestAsync(r => r Assert.Equal("test", ev.Message); Assert.Equal("TestReferenceId", ev.ReferenceId); - var identity = ev.GetUserIdentity(); + var identity = ev.GetUserIdentity(jsonOptions); Assert.NotNull(identity); Assert.Equal("Test user", identity.Identity); Assert.Null(identity.Name); Assert.Null(identity.Name); - Assert.Null(ev.GetUserDescription()); + Assert.Null(ev.GetUserDescription(jsonOptions)); // post description await _eventUserDescriptionQueue.DeleteQueueAsync(); @@ -122,20 +125,20 @@ await SendRequestAsync(r => r Assert.Equal(1, stats.Completed); ev = await _eventRepository.GetByIdAsync(ev.Id); - identity = ev.GetUserIdentity(); + identity = ev.GetUserIdentity(jsonOptions); Assert.NotNull(identity); Assert.Equal("Test user", identity.Identity); Assert.Null(identity.Name); Assert.Null(identity.Name); - var description = ev.GetUserDescription(); + var description = ev.GetUserDescription(jsonOptions); Assert.NotNull(description); Assert.Equal("Test Description", description.Description); Assert.Equal(TestConstants.UserEmail, description.EmailAddress); } [Fact] - public async Task CanPostUserDescriptionWithNoMatchingEventAsync() + public async Task PostEvent_WithNoMatchingEvent_UserDescriptionIsAbandonedAsync() { await SendRequestAsync(r => r .Post() @@ -224,7 +227,9 @@ public async Task CanPostCompressedStringAsync() [Fact] public async Task CanPostJsonWithUserInfoAsync() { - const string json = "{\"message\":\"test\",\"@user\":{\"identity\":\"Test user\",\"name\":null}}"; + var jsonOptions = GetService(); + /* language=json */ + const string json = """{"message":"test","@user":{"identity":"Test user","name":null}}"""; await SendRequestAsync(r => r .Post() .AsTestOrganizationClientUser() @@ -248,7 +253,7 @@ await SendRequestAsync(r => r var ev = events.Documents.Single(e => String.Equals(e.Type, Event.KnownTypes.Log)); Assert.Equal("test", ev.Message); - var userInfo = ev.GetUserIdentity(); + var userInfo = ev.GetUserIdentity(jsonOptions); Assert.NotNull(userInfo); Assert.Equal("Test user", userInfo.Identity); Assert.Null(userInfo.Name); @@ -1084,10 +1089,11 @@ await SendRequestAsync(r => r .StatusCodeShouldBeNotFound() ); + // /docs/{documentName} is now handled by Scalar API documentation await SendRequestAsync(r => r .BaseUri(_server.BaseAddress) .AppendPaths("docs", "blah") - .StatusCodeShouldBeNotFound() + .StatusCodeShouldBeOk() ); } @@ -1586,13 +1592,16 @@ await SendRequestAsync(r => r public async Task PostEvent_WithExtraRootProperties_CapturedInDataBag() { // Arrange: Create a JSON event with extra root properties that should go into the data bag - var json = @"{ - ""type"": ""log"", - ""message"": ""Test with extra properties"", - ""custom_field"": ""custom_value"", - ""custom_number"": 42, - ""custom_flag"": true - }"; + /* language=json */ + const string json = """ + { + "type": "log", + "message": "Test with extra properties", + "custom_field": "custom_value", + "custom_number": 42, + "custom_flag": true + } + """; // Act: POST the event with extra root properties await SendRequestAsync(r => r @@ -1624,7 +1633,7 @@ await SendRequestAsync(r => r { Assert.Equal("custom_value", ev.Data["custom_field"]); Assert.Equal(42L, ev.Data["custom_number"]); - Assert.Equal(true, ev.Data["custom_flag"]); + Assert.True(ev.Data["custom_flag"] as bool?); } } @@ -1632,17 +1641,20 @@ await SendRequestAsync(r => r public async Task PostEvent_WithExtraPropertiesAndKnownData_PreservesAllData() { // Arrange: Create a JSON event with both known data keys and extra properties - var json = @"{ - ""type"": ""error"", - ""message"": ""Error with mixed data"", - ""@user"": { - ""identity"": ""user@example.com"", - ""name"": ""Test User"" - }, - ""extra_field_1"": ""value1"", - ""extra_field_2"": 99, - ""@version"": ""1.0.0"" - }"; + /* language=json */ + const string json = """ + { + "type": "error", + "message": "Error with mixed data", + "@user": { + "identity": "user@example.com", + "name": "Test User" + }, + "extra_field_1": "value1", + "extra_field_2": 99, + "@version": "1.0.0" + } + """; // Act await SendRequestAsync(r => r @@ -1657,6 +1669,8 @@ await SendRequestAsync(r => r await processEventsJob.RunAsync(TestCancellationToken); await RefreshDataAsync(); + var jsonOptions = GetService(); + // Assert var events = await _eventRepository.GetAllAsync(); var ev = events.Documents.Single(e => !e.IsSessionStart()); @@ -1665,23 +1679,20 @@ await SendRequestAsync(r => r Assert.Equal("Error with mixed data", ev.Message); // Verify known data is properly deserialized - var userInfo = ev.GetUserIdentity(); + var userInfo = ev.GetUserIdentity(jsonOptions); Assert.NotNull(userInfo); Assert.Equal("user@example.com", userInfo.Identity); Assert.Equal("Test User", userInfo.Name); // Verify version is captured - var version = ev.GetVersion(); + string? version = ev.GetVersion(); Assert.Equal("1.0.0", version); // Verify extra properties are captured if JsonExtensionData is implemented - if (ev.Data is not null) + if (ev.Data is not null && ev.Data.TryGetValue("extra_field_1", out object? value)) { - if (ev.Data.ContainsKey("extra_field_1")) - { - Assert.Equal("value1", ev.Data["extra_field_1"]); - Assert.Equal(99L, ev.Data["extra_field_2"]); - } + Assert.Equal("value1", value); + Assert.Equal(99L, ev.Data["extra_field_2"]); } } @@ -1689,18 +1700,21 @@ await SendRequestAsync(r => r public async Task PostEvent_WithExtraComplexProperties_CapturedAsObjects() { // Arrange: Create a JSON event with complex extra properties (nested objects) - var json = @"{ - ""type"": ""log"", - ""message"": ""Test with complex properties"", - ""metadata"": { - ""key1"": ""value1"", - ""key2"": 42, - ""nested"": { - ""inner"": ""value"" - } - }, - ""tags_list"": [""tag1"", ""tag2"", ""tag3""] - }"; + /* language=json */ + const string json = """ + { + "type": "log", + "message": "Test with complex properties", + "metadata": { + "key1": "value1", + "key2": 42, + "nested": { + "inner": "value" + } + }, + "tags_list": ["tag1", "tag2", "tag3"] + } + """; // Act await SendRequestAsync(r => r @@ -1731,13 +1745,16 @@ await SendRequestAsync(r => r public async Task PostEvent_WithSnakeCaseAndPascalCaseProperties_HandledCorrectly() { // Arrange: Create a JSON event with mixed naming conventions - var json = @"{ - ""type"": ""log"", - ""message"": ""Test naming conventions"", - ""reference_id"": ""ref-1234567890"", - ""custom_snake_case"": ""snake_value"", - ""CustomPascalCase"": ""pascal_value"" - }"; + /* language=json */ + const string json = """ + { + "type": "log", + "message": "Test naming conventions", + "reference_id": "ref-1234567890", + "custom_snake_case": "snake_value", + "CustomPascalCase": "pascal_value" + } + """; // Act await SendRequestAsync(r => r diff --git a/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs index 78fe007173..5e93e13e08 100644 --- a/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs @@ -13,12 +13,12 @@ public OpenApiControllerTests(ITestOutputHelper output, AppWebHostFactory factor public async Task GetSwaggerJson_Default_ReturnsExpectedBaseline() { // Arrange - string baselinePath = Path.Combine("..", "..", "..", "Controllers", "Data", "swagger.json"); + string baselinePath = Path.Combine("..", "..", "..", "Controllers", "Data", "openapi.json"); // Act var response = await SendRequestAsync(r => r .BaseUri(_server.BaseAddress) - .AppendPaths("docs", "v2", "swagger.json") + .AppendPaths("docs", "v2", "openapi.json") .StatusCodeShouldBeOk() ); diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 374d948f0f..debf15bd89 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -72,7 +72,7 @@ public async Task GetAsync_ExistingOrganization_MapsToViewOrganization() // Assert - Verify mapped Organization -> ViewOrganization correctly Assert.NotNull(viewOrg); Assert.Equal(SampleDataService.TEST_ORG_ID, viewOrg.Id); - Assert.False(string.IsNullOrEmpty(viewOrg.Name)); + Assert.False(String.IsNullOrEmpty(viewOrg.Name)); Assert.NotNull(viewOrg.PlanId); Assert.NotNull(viewOrg.PlanName); } diff --git a/tests/Exceptionless.Tests/Mail/MailerTests.cs b/tests/Exceptionless.Tests/Mail/MailerTests.cs index d8171f3386..5f381bb418 100644 --- a/tests/Exceptionless.Tests/Mail/MailerTests.cs +++ b/tests/Exceptionless.Tests/Mail/MailerTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; @@ -39,7 +40,7 @@ public MailerTests(ITestOutputHelper output) : base(output) _plans = GetService(); if (_mailer is NullMailer) - _mailer = new Mailer(GetService>(), GetService(), _options, TimeProvider, Log.CreateLogger()); + _mailer = new Mailer(GetService>(), GetService(), GetService(), _options, TimeProvider, Log.CreateLogger()); } [Fact] diff --git a/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs b/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs index 1e287d5d0b..74fff49ebc 100644 --- a/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs +++ b/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Globalization; using System.Text; +using System.Text.Json; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; @@ -38,6 +39,7 @@ public sealed class EventPipelineTests : IntegrationTestsBase private readonly IUserRepository _userRepository; private readonly BillingManager _billingManager; private readonly BillingPlans _plans; + private readonly JsonSerializerOptions _jsonOptions; public EventPipelineTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { @@ -53,6 +55,7 @@ public EventPipelineTests(ITestOutputHelper output, AppWebHostFactory factory) : _pipeline = GetService(); _billingManager = GetService(); _plans = GetService(); + _jsonOptions = GetService(); } protected override async Task ResetDataAsync() @@ -221,19 +224,19 @@ public async Task UpdateAutoSessionLastActivityAsync() var results = await _eventRepository.GetAllAsync(o => o.PageLimit(15)); Assert.Equal(9, results.Total); Assert.Equal(2, results.Documents.Where(e => !String.IsNullOrEmpty(e.GetSessionId())).Select(e => e.GetSessionId()).Distinct().Count()); - Assert.Equal(1, results.Documents.Count(e => e.IsSessionEnd() && e.GetUserIdentity()?.Identity == "blake@exceptionless.io")); - Assert.Single(results.Documents.Where(e => !String.IsNullOrEmpty(e.GetSessionId()) && e.GetUserIdentity()?.Identity == "eric@exceptionless.io").Select(e => e.GetSessionId()).Distinct()); + Assert.Equal(1, results.Documents.Count(e => e.IsSessionEnd() && e.GetUserIdentity(_jsonOptions)?.Identity == "blake@exceptionless.io")); + Assert.Single(results.Documents.Where(e => !String.IsNullOrEmpty(e.GetSessionId()) && e.GetUserIdentity(_jsonOptions)?.Identity == "eric@exceptionless.io").Select(e => e.GetSessionId()).Distinct()); Assert.Equal(1, results.Documents.Count(e => String.IsNullOrEmpty(e.GetSessionId()))); Assert.Equal(1, results.Documents.Count(e => e.IsSessionEnd())); var sessionStarts = results.Documents.Where(e => e.IsSessionStart()).ToList(); Assert.Equal(2, sessionStarts.Count); - var firstUserSessionStartEvents = sessionStarts.Single(e => e.GetUserIdentity()?.Identity == "blake@exceptionless.io"); + var firstUserSessionStartEvents = sessionStarts.Single(e => e.GetUserIdentity(_jsonOptions)?.Identity == "blake@exceptionless.io"); Assert.Equal((decimal)(lastEventDate - firstEventDate).TotalSeconds, firstUserSessionStartEvents.Value); Assert.True(firstUserSessionStartEvents.HasSessionEndTime()); - var secondUserSessionStartEvents = sessionStarts.Single(e => e.GetUserIdentity()?.Identity == "eric@exceptionless.io"); + var secondUserSessionStartEvents = sessionStarts.Single(e => e.GetUserIdentity(_jsonOptions)?.Identity == "eric@exceptionless.io"); Assert.Equal((decimal)(lastEventDate - firstEventDate).TotalSeconds, secondUserSessionStartEvents.Value); Assert.False(secondUserSessionStartEvents.HasSessionEndTime()); } @@ -890,10 +893,10 @@ public async Task EnsureIncludePrivateInformationIsRespectedAsync(bool includePr var context = contexts.Single(); Assert.False(context.HasError); - var requestInfo = context.Event.GetRequestInfo(); - var environmentInfo = context.Event.GetEnvironmentInfo(); - var userInfo = context.Event.GetUserIdentity(); - var userDescription = context.Event.GetUserDescription(); + var requestInfo = context.Event.GetRequestInfo(_jsonOptions); + var environmentInfo = context.Event.GetEnvironmentInfo(_jsonOptions); + var userInfo = context.Event.GetUserIdentity(_jsonOptions); + var userDescription = context.Event.GetUserDescription(_jsonOptions); Assert.Equal("/test", requestInfo?.Path); Assert.Equal("Windows", environmentInfo?.OSName); @@ -1160,7 +1163,7 @@ public async Task GeneratePerformanceDataAsync() ev.Data.Remove(key); ev.Data.Remove(Event.KnownDataKeys.UserDescription); - var identity = ev.GetUserIdentity(); + var identity = ev.GetUserIdentity(_jsonOptions); if (identity?.Identity is not null) { if (!mappedUsers.ContainsKey(identity.Identity)) @@ -1169,7 +1172,7 @@ public async Task GeneratePerformanceDataAsync() ev.SetUserIdentity(mappedUsers[identity.Identity]); } - var request = ev.GetRequestInfo(); + var request = ev.GetRequestInfo(_jsonOptions); if (request is not null) { request.Cookies?.Clear(); @@ -1189,7 +1192,7 @@ public async Task GeneratePerformanceDataAsync() } } - InnerError? error = ev.GetError(); + InnerError? error = ev.GetError(_jsonOptions); while (error is not null) { error.Message = RandomData.GetSentence(); @@ -1199,13 +1202,13 @@ public async Task GeneratePerformanceDataAsync() error = error.Inner; } - var environment = ev.GetEnvironmentInfo(); + var environment = ev.GetEnvironmentInfo(_jsonOptions); environment?.Data?.Clear(); } // inject random session start events. if (currentBatchCount % 10 == 0) - events.Insert(0, events[0].ToSessionStartEvent()); + events.Insert(0, events[0].ToSessionStartEvent(_jsonOptions)); await storage.SaveObjectAsync(Path.Combine(dataDirectory, $"{currentBatchCount++}.json"), events, TestCancellationToken); } diff --git a/tests/Exceptionless.Tests/Plugins/GeoTests.cs b/tests/Exceptionless.Tests/Plugins/GeoTests.cs index be84a7032d..fe6c576fb5 100644 --- a/tests/Exceptionless.Tests/Plugins/GeoTests.cs +++ b/tests/Exceptionless.Tests/Plugins/GeoTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text.Json; using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Geo; @@ -28,6 +29,7 @@ public sealed class GeoTests : TestWithServices private readonly AppOptions _options; private readonly OrganizationData _organizationData; private readonly ProjectData _projectData; + private readonly JsonSerializerOptions _jsonOptions; public GeoTests(ITestOutputHelper output) : base(output) { @@ -36,6 +38,7 @@ public GeoTests(ITestOutputHelper output) : base(output) _options = GetService(); _organizationData = GetService(); _projectData = GetService(); + _jsonOptions = GetService(); } private async Task GetResolverAsync(ILoggerFactory loggerFactory) @@ -71,12 +74,12 @@ public async Task WillNotSetLocation() if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _options, Log); + var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); var ev = new PersistentEvent { Geo = GREEN_BAY_COORDINATES }; await plugin.EventBatchProcessingAsync(new List { new(ev, _organizationData.GenerateSampleOrganization(_billingManager, _plans), _projectData.GenerateSampleProject()) }); Assert.Equal(GREEN_BAY_COORDINATES, ev.Geo); - Assert.Null(ev.GetLocation()); + Assert.Null(ev.GetLocation(_jsonOptions)); } [Theory] @@ -91,12 +94,12 @@ public async Task WillResetLocation(string? geo) if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _options, Log); + var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); var ev = new PersistentEvent { Geo = geo }; await plugin.EventBatchProcessingAsync(new List { new(ev, _organizationData.GenerateSampleOrganization(_billingManager, _plans), _projectData.GenerateSampleProject()) }); Assert.Null(ev.Geo); - Assert.Null(ev.GetLocation()); + Assert.Null(ev.GetLocation(_jsonOptions)); } [Fact] @@ -106,14 +109,14 @@ public async Task WillSetLocationFromGeo() if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _options, Log); + var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); var ev = new PersistentEvent { Geo = GREEN_BAY_IP }; await plugin.EventBatchProcessingAsync(new List { new(ev, _organizationData.GenerateSampleOrganization(_billingManager, _plans), _projectData.GenerateSampleProject()) }); Assert.NotNull(ev.Geo); Assert.NotEqual(GREEN_BAY_IP, ev.Geo); - var location = ev.GetLocation(); + var location = ev.GetLocation(_jsonOptions); Assert.Equal("US", location?.Country); Assert.Equal("WI", location?.Level1); Assert.Equal("Green Bay", location?.Locality); @@ -126,14 +129,14 @@ public async Task WillSetLocationFromRequestInfo() if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _options, Log); + var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); var ev = new PersistentEvent(); ev.AddRequestInfo(new RequestInfo { ClientIpAddress = GREEN_BAY_IP }); await plugin.EventBatchProcessingAsync(new List { new(ev, _organizationData.GenerateSampleOrganization(_billingManager, _plans), _projectData.GenerateSampleProject()) }); Assert.NotNull(ev.Geo); - var location = ev.GetLocation(); + var location = ev.GetLocation(_jsonOptions); Assert.Equal("US", location?.Country); Assert.Equal("WI", location?.Level1); Assert.Equal("Green Bay", location?.Locality); @@ -146,14 +149,14 @@ public async Task WillSetLocationFromEnvironmentInfoInfo() if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _options, Log); + var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); var ev = new PersistentEvent(); ev.SetEnvironmentInfo(new EnvironmentInfo { IpAddress = $"127.0.0.1,{GREEN_BAY_IP}" }); await plugin.EventBatchProcessingAsync(new List { new(ev, _organizationData.GenerateSampleOrganization(_billingManager, _plans), _projectData.GenerateSampleProject()) }); Assert.NotNull(ev.Geo); - var location = ev.GetLocation(); + var location = ev.GetLocation(_jsonOptions); Assert.Equal("US", location?.Country); Assert.Equal("WI", location?.Level1); Assert.Equal("Green Bay", location?.Locality); @@ -166,7 +169,7 @@ public async Task WillSetFromSingleGeo() if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _options, Log); + var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); var contexts = new List { new(new PersistentEvent { Geo = GREEN_BAY_IP }, _organizationData.GenerateSampleOrganization(_billingManager, _plans), _projectData.GenerateSampleProject()), @@ -179,7 +182,7 @@ public async Task WillSetFromSingleGeo() { AssertCoordinatesAreEqual(GREEN_BAY_COORDINATES, context.Event.Geo); - var location = context.Event.GetLocation(); + var location = context.Event.GetLocation(_jsonOptions); Assert.Equal("US", location?.Country); Assert.Equal("WI", location?.Level1); Assert.Equal("Green Bay", location?.Locality); @@ -193,7 +196,7 @@ public async Task WillNotSetFromMultipleGeo() if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _options, Log); + var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); var ev = new PersistentEvent(); var greenBayEvent = new PersistentEvent { Geo = GREEN_BAY_IP }; @@ -205,13 +208,13 @@ await plugin.EventBatchProcessingAsync(new List { }); AssertCoordinatesAreEqual(GREEN_BAY_COORDINATES, greenBayEvent.Geo); - var location = greenBayEvent.GetLocation(); + var location = greenBayEvent.GetLocation(_jsonOptions); Assert.Equal("US", location?.Country); Assert.Equal("WI", location?.Level1); Assert.Equal("Green Bay", location?.Locality); AssertCoordinatesAreEqual(IRVING_COORDINATES, irvingEvent.Geo); - location = irvingEvent.GetLocation(); + location = irvingEvent.GetLocation(_jsonOptions); Assert.Equal("US", location?.Country); Assert.Equal("TX", location?.Level1); Assert.Equal("Irving", location?.Locality); @@ -239,7 +242,7 @@ public async Task WillSetMultipleFromEmptyGeo() if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _options, Log); + var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); var ev = new PersistentEvent(); var greenBayEvent = new PersistentEvent(); @@ -253,13 +256,13 @@ await plugin.EventBatchProcessingAsync(new List { }); AssertCoordinatesAreEqual(GREEN_BAY_COORDINATES, greenBayEvent.Geo); - var location = greenBayEvent.GetLocation(); + var location = greenBayEvent.GetLocation(_jsonOptions); Assert.Equal("US", location?.Country); Assert.Equal("WI", location?.Level1); Assert.Equal("Green Bay", location?.Locality); AssertCoordinatesAreEqual(IRVING_COORDINATES, irvingEvent.Geo); - location = irvingEvent.GetLocation(); + location = irvingEvent.GetLocation(_jsonOptions); Assert.Equal("US", location?.Country); Assert.Equal("TX", location?.Level1); Assert.Equal("Irving", location?.Locality); diff --git a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs index 9902231841..6e7ba9c3c4 100644 --- a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories; @@ -20,6 +22,7 @@ public sealed class EventRepositoryTests : IntegrationTestsBase private readonly IEventRepository _repository; private readonly StackData _stackData; private readonly IStackRepository _stackRepository; + private readonly JsonSerializerOptions _jsonOptions; public EventRepositoryTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { @@ -28,6 +31,7 @@ public EventRepositoryTests(ITestOutputHelper output, AppWebHostFactory factory) _repository = GetService(); _stackData = GetService(); _stackRepository = GetService(); + _jsonOptions = GetService(); } [Fact(Skip = "https://github.com/elastic/elasticsearch-net/issues/2463")] @@ -216,7 +220,7 @@ public async Task RemoveAllByClientIpAndDateAsync() Assert.Equal(NUMBER_OF_EVENTS_TO_CREATE, events.Count); events.ForEach(e => { - var ri = e.GetRequestInfo(); + var ri = e.GetRequestInfo(_jsonOptions); Assert.NotNull(ri); Assert.Equal(_clientIpAddress, ri.ClientIpAddress); }); diff --git a/tests/Exceptionless.Tests/Search/EventStackFilterQueryVisitorTests.cs b/tests/Exceptionless.Tests/Search/EventStackFilterQueryVisitorTests.cs index 45fe38a6bf..ef07e44c4b 100644 --- a/tests/Exceptionless.Tests/Search/EventStackFilterQueryVisitorTests.cs +++ b/tests/Exceptionless.Tests/Search/EventStackFilterQueryVisitorTests.cs @@ -204,7 +204,7 @@ public override string ToString() public void Deserialize(IXunitSerializationInfo info) { - var jsonValue = info.GetValue("objValue") ?? throw new InvalidOperationException("Missing objValue"); + string jsonValue = info.GetValue("objValue") ?? throw new InvalidOperationException("Missing objValue"); var value = JsonConvert.DeserializeObject(jsonValue) ?? throw new InvalidOperationException("Failed to deserialize"); Source = value.Source; Stack = value.Stack; diff --git a/tests/Exceptionless.Tests/Serializer/ITextSerializerContractTests.cs b/tests/Exceptionless.Tests/Serializer/ITextSerializerContractTests.cs deleted file mode 100644 index 9e15d15d06..0000000000 --- a/tests/Exceptionless.Tests/Serializer/ITextSerializerContractTests.cs +++ /dev/null @@ -1,355 +0,0 @@ -using Foundatio.Serializer; -using Xunit; - -namespace Exceptionless.Tests.Serializer; - -/// -/// Tests the ITextSerializer interface contract. -/// Validates that all serialization methods work correctly and consistently. -/// These tests ensure the serializer can be swapped without breaking functionality. -/// -public class ITextSerializerContractTests : TestWithServices -{ - private readonly ITextSerializer _serializer; - - public ITextSerializerContractTests(ITestOutputHelper output) : base(output) - { - _serializer = GetService(); - } - - private sealed record SimpleModel(string Name, int Value); - - [Fact] - public void SerializeToString_WithSimpleObject_ProducesExpectedJson() - { - // Arrange - var model = new SimpleModel("test", 42); - - /* language=json */ - const string expectedJson = """{"name":"test","value":42}"""; - - // Act - var json = _serializer.SerializeToString(model); - - // Assert - Assert.Equal(expectedJson, json); - } - - [Fact] - public void SerializeToString_WithNullObject_ReturnsNull() - { - // Arrange - SimpleModel? model = null; - - // Act - var json = _serializer.SerializeToString(model); - - // Assert - Assert.Null(json); - } - - [Fact] - public void SerializeToString_WithEmptyStringProperty_SerializesEmptyString() - { - // Arrange - var model = new SimpleModel("", 0); - - /* language=json */ - const string expectedJson = """{"name":"","value":0}"""; - - // Act - var json = _serializer.SerializeToString(model); - - // Assert - Assert.Equal(expectedJson, json); - } - - [Fact] - public void SerializeToString_WithSpecialCharacters_EscapesCorrectly() - { - // Arrange - var model = new SimpleModel("line1\nline2\ttab\"quote", 0); - - /* language=json */ - const string expectedJson = """{"name":"line1\nline2\ttab\"quote","value":0}"""; - - // Act - var json = _serializer.SerializeToString(model); - - // Assert - Assert.Equal(expectedJson, json); - } - - [Fact] - public void SerializeToBytes_WithSimpleObject_ProducesUtf8Bytes() - { - // Arrange - var model = new SimpleModel("test", 42); - - /* language=json */ - const string expectedJson = """{"name":"test","value":42}"""; - var expectedBytes = System.Text.Encoding.UTF8.GetBytes(expectedJson); - - // Act - var bytes = _serializer.SerializeToBytes(model); - - // Assert - Assert.Equal(expectedBytes, bytes.ToArray()); - } - - [Fact] - public void SerializeToBytes_WithUnicodeString_ProducesCorrectUtf8() - { - // Arrange - var model = new SimpleModel("日本語", 1); - - /* language=json */ - const string expectedJson = """{"name":"日本語","value":1}"""; - var expectedBytes = System.Text.Encoding.UTF8.GetBytes(expectedJson); - - // Act - var bytes = _serializer.SerializeToBytes(model); - - // Assert - Assert.Equal(expectedBytes, bytes.ToArray()); - } - - [Fact] - public void Serialize_ToStream_WritesExpectedJson() - { - // Arrange - var model = new SimpleModel("stream", 99); - - /* language=json */ - const string expectedJson = """{"name":"stream","value":99}"""; - using var stream = new MemoryStream(); - - // Act - _serializer.Serialize(model, stream); - - // Assert - stream.Position = 0; - using var reader = new StreamReader(stream); - var json = reader.ReadToEnd(); - Assert.Equal(expectedJson, json); - } - - [Fact] - public void Deserialize_WithValidJson_ReturnsPopulatedModel() - { - // Arrange - /* language=json */ - const string json = """{"name":"parsed","value":123}"""; - - // Act - var model = _serializer.Deserialize(json); - - // Assert - Assert.NotNull(model); - Assert.Equal("parsed", model.Name); - Assert.Equal(123, model.Value); - } - - [Fact] - public void Deserialize_WithNullLiteral_ReturnsNull() - { - // Arrange - /* language=json */ - const string json = "null"; - - // Act - var model = _serializer.Deserialize(json); - - // Assert - Assert.Null(model); - } - - [Fact] - public void Deserialize_WithEmptyString_ReturnsNull() - { - // Arrange - var json = string.Empty; - - // Act - var model = _serializer.Deserialize(json); - - // Assert - Assert.Null(model); - } - - [Fact] - public void Deserialize_WithWhitespaceOnlyString_ReturnsNull() - { - // Arrange - const string json = " "; - - // Act - var model = _serializer.Deserialize(json); - - // Assert - Assert.Null(model); - } - - [Fact] - public void Deserialize_FromStream_ReturnsPopulatedModel() - { - // Arrange - /* language=json */ - const string json = """{"name":"from_stream","value":456}"""; - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - - // Act - var model = _serializer.Deserialize(stream); - - // Assert - Assert.NotNull(model); - Assert.Equal("from_stream", model.Name); - Assert.Equal(456, model.Value); - } - - [Fact] - public void Deserialize_WithInvalidJson_ThrowsException() - { - // Arrange - const string invalidJson = "{ invalid json }"; - - // Act & Assert - Assert.ThrowsAny(() => _serializer.Deserialize(invalidJson)); - } - - [Fact] - public void Deserialize_WithSpecialCharacters_PreservesData() - { - // Arrange - /* language=json */ - const string json = """{"name":"line1\nline2\ttab\"quote\\backslash","value":0}"""; - - // Act - var model = _serializer.Deserialize(json); - - // Assert - Assert.NotNull(model); - Assert.Equal("line1\nline2\ttab\"quote\\backslash", model.Name); - } - - [Fact] - public void SerializeToString_ThenDeserialize_PreservesStringData() - { - // Arrange - var original = new SimpleModel("round-trip", 789); - - /* language=json */ - const string expectedJson = """{"name":"round-trip","value":789}"""; - - // Act - var json = _serializer.SerializeToString(original); - var deserialized = _serializer.Deserialize(json); - - // Assert - Assert.Equal(expectedJson, json); - Assert.NotNull(deserialized); - Assert.Equal(original.Name, deserialized.Name); - Assert.Equal(original.Value, deserialized.Value); - } - - [Fact] - public void SerializeToBytes_ThenDeserialize_PreservesBytesData() - { - // Arrange - var original = new SimpleModel("bytes-trip", 321); - - // Act - var bytes = _serializer.SerializeToBytes(original); - using var stream = new MemoryStream(bytes.ToArray()); - var deserialized = _serializer.Deserialize(stream); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.Name, deserialized.Name); - Assert.Equal(original.Value, deserialized.Value); - } - - [Fact] - public void Serialize_ThenDeserialize_ThroughStream_PreservesData() - { - // Arrange - var original = new SimpleModel("stream-trip", 654); - using var stream = new MemoryStream(); - - // Act - _serializer.Serialize(original, stream); - stream.Position = 0; - var deserialized = _serializer.Deserialize(stream); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.Name, deserialized.Name); - Assert.Equal(original.Value, deserialized.Value); - } - - private sealed class ComplexModel - { - public string? Id { get; set; } - public List Tags { get; set; } = []; - public Dictionary Metadata { get; set; } = []; - public NestedModel? Nested { get; set; } - } - - private sealed class NestedModel - { - public string? Description { get; set; } - public int Priority { get; set; } - } - - [Fact] - public void SerializeToString_WithComplexObject_ProducesExpectedJson() - { - // Arrange - var model = new ComplexModel - { - Id = "complex1", - Tags = ["tag1", "tag2"], - Metadata = new Dictionary - { - ["key1"] = "value1", - ["key2"] = 42 - }, - Nested = new NestedModel - { - Description = "nested desc", - Priority = 5 - } - }; - - /* language=json */ - const string expectedJson = """{"id":"complex1","tags":["tag1","tag2"],"metadata":{"key1":"value1","key2":42},"nested":{"description":"nested desc","priority":5}}"""; - - // Act - var json = _serializer.SerializeToString(model); - - // Assert - Assert.Equal(expectedJson, json); - } - - [Fact] - public void Deserialize_WithComplexJson_ReturnsPopulatedModel() - { - // Arrange - /* language=json */ - const string json = """{"id":"complex-rt","tags":["a","b","c"],"metadata":{"count":100,"enabled":true},"nested":{"description":"test","priority":10}}"""; - - // Act - var model = _serializer.Deserialize(json); - - // Assert - Assert.NotNull(model); - Assert.Equal("complex-rt", model.Id); - Assert.Equal(3, model.Tags.Count); - Assert.Contains("a", model.Tags); - Assert.Contains("b", model.Tags); - Assert.Contains("c", model.Tags); - Assert.NotNull(model.Nested); - Assert.Equal("test", model.Nested.Description); - Assert.Equal(10, model.Nested.Priority); - } -} diff --git a/tests/Exceptionless.Tests/Serializer/LowerCaseUnderscoreNamingPolicyTests.cs b/tests/Exceptionless.Tests/Serializer/LowerCaseUnderscoreNamingPolicyTests.cs new file mode 100644 index 0000000000..f03f8da7c5 --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/LowerCaseUnderscoreNamingPolicyTests.cs @@ -0,0 +1,214 @@ +using System.Text.Json; +using Exceptionless.Core.Models; +using Exceptionless.Core.Serialization; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Xunit; +using Xunit; + +namespace Exceptionless.Tests.Serializer; + +/// +/// Tests for LowerCaseUnderscoreNamingPolicy and System.Text.Json serialization for the API layer. +/// +public class LowerCaseUnderscoreNamingPolicyTests : TestWithLoggingBase +{ + private readonly JsonSerializerOptions _jsonSerializerOptions; + + public LowerCaseUnderscoreNamingPolicyTests(ITestOutputHelper output) : base(output) + { + _jsonSerializerOptions = new() + { + PropertyNamingPolicy = LowerCaseUnderscoreNamingPolicy.Instance, + Converters = { new DeltaJsonConverterFactory() } + }; + } + + [Fact] + public void NamingPolicy_Instance_ReturnsSingleton() + { + // Arrange + var instance1 = LowerCaseUnderscoreNamingPolicy.Instance; + + // Act + var instance2 = LowerCaseUnderscoreNamingPolicy.Instance; + + // Assert + Assert.Same(instance1, instance2); + } + + [Fact] + public void NamingPolicy_AppOptionsProperties_SerializesCorrectly() + { + // Arrange + var model = new AppOptionsModel + { + BaseURL = "https://example.com", + EnableSSL = true, + MaximumRetentionDays = 180, + WebsiteMode = "production" + }; + + // Act + string json = JsonSerializer.Serialize(model, _jsonSerializerOptions); + + // Assert + /* language=json */ + const string expected = """{"base_u_r_l":"https://example.com","enable_s_s_l":true,"maximum_retention_days":180,"website_mode":"production"}"""; + Assert.Equal(expected, json); + } + + [Fact] + public void NamingPolicy_EnvironmentProperties_SerializesCorrectly() + { + // Arrange + // Properties from event-serialization-input.json + var model = new EnvironmentModel + { + OSName = "Windows 11", + OSVersion = "10.0.22621", + IPAddress = "192.168.1.100", + MachineName = "TEST-MACHINE" + }; + + // Act + string json = JsonSerializer.Serialize(model, _jsonSerializerOptions); + + // Assert + /* language=json */ + const string expected = """{"o_s_name":"Windows 11","o_s_version":"10.0.22621","i_p_address":"192.168.1.100","machine_name":"TEST-MACHINE"}"""; + Assert.Equal(expected, json); + } + + [Fact] + public void ExternalAuthInfo_Serialize_UsesCamelCasePropertyNames() + { + // Arrange + var authInfo = new ExternalAuthInfo + { + ClientId = "test-client", + Code = "auth-code", + RedirectUri = "https://example.com/callback", + InviteToken = "token123" + }; + + // Act + string json = JsonSerializer.Serialize(authInfo, _jsonSerializerOptions); + + // Assert + // ExternalAuthInfo uses explicit JsonPropertyName attributes (camelCase) + /* language=json */ + const string expected = """{"clientId":"test-client","code":"auth-code","redirectUri":"https://example.com/callback","inviteToken":"token123"}"""; + Assert.Equal(expected, json); + } + + [Fact] + public void ExternalAuthInfo_Deserialize_ParsesCamelCaseJson() + { + // Arrange + /* language=json */ + const string json = """{"clientId": "my-client", "code": "my-code", "redirectUri": "https://test.com"}"""; + + // Act + var authInfo = JsonSerializer.Deserialize(json, _jsonSerializerOptions); + + // Assert + Assert.NotNull(authInfo); + Assert.Equal("my-client", authInfo.ClientId); + Assert.Equal("my-code", authInfo.Code); + Assert.Equal("https://test.com", authInfo.RedirectUri); + Assert.Null(authInfo.InviteToken); + } + + [Fact] + public void Delta_Deserialize_SnakeCaseJson_SetsPropertyValues() + { + // Arrange + /* language=json */ + const string json = """{"data": "TestValue", "is_active": true}"""; + + // Act + var delta = JsonSerializer.Deserialize>(json, _jsonSerializerOptions); + + // Assert + Assert.NotNull(delta); + Assert.True(delta.TryGetPropertyValue("Data", out object? dataValue)); + Assert.Equal("TestValue", dataValue); + Assert.True(delta.TryGetPropertyValue("IsActive", out object? isActiveValue)); + Assert.True(isActiveValue as bool?); + } + + [Fact] + public void Delta_Deserialize_PartialUpdate_OnlyTracksProvidedProperties() + { + // Arrange + /* language=json */ + const string json = """{"is_active": false}"""; + + // Act + var delta = JsonSerializer.Deserialize>(json, _jsonSerializerOptions); + + // Assert + Assert.NotNull(delta); + var changedProperties = delta.GetChangedPropertyNames(); + Assert.Single(changedProperties); + Assert.Contains("IsActive", changedProperties); + } + + [Fact] + public void StackStatus_Serialize_UsesStringValue() + { + // Arrange + var stack = new StackStatusModel { Status = StackStatus.Fixed }; + + // Act + string json = JsonSerializer.Serialize(stack, _jsonSerializerOptions); + + // Assert + /* language=json */ + const string expected = """{"status":"fixed"}"""; + Assert.Equal(expected, json); + } + + [Fact] + public void StackStatus_Deserialize_ParsesStringValue() + { + // Arrange + /* language=json */ + const string json = """{"status": "regressed"}"""; + + // Act + var model = JsonSerializer.Deserialize(json, _jsonSerializerOptions); + + // Assert + Assert.NotNull(model); + Assert.Equal(StackStatus.Regressed, model.Status); + } + + private class AppOptionsModel + { + public string? BaseURL { get; set; } + public bool EnableSSL { get; set; } + public int MaximumRetentionDays { get; set; } + public string? WebsiteMode { get; set; } + } + + private class EnvironmentModel + { + public string? OSName { get; set; } + public string? OSVersion { get; set; } + public string? IPAddress { get; set; } + public string? MachineName { get; set; } + } + + private class SimpleModel + { + public string? Data { get; set; } + public bool IsActive { get; set; } + } + + private class StackStatusModel + { + public StackStatus Status { get; set; } + } +} diff --git a/tests/Exceptionless.Tests/Serializer/Models/DataDictionaryTests.cs b/tests/Exceptionless.Tests/Serializer/Models/DataDictionaryTests.cs index 8dd9885fa5..cc91b0837c 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/DataDictionaryTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/DataDictionaryTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; @@ -8,16 +9,18 @@ namespace Exceptionless.Tests.Serializer.Models; /// -/// Tests for DataDictionary.GetValue<T>() extension method. -/// Verifies support for Newtonsoft.Json (JObject) and JSON strings. +/// Tests for DataDictionary.GetValue extension method. +/// Verifies deserialization from typed objects, JObject (Elasticsearch), JSON strings, and round-trips. /// public class DataDictionaryTests : TestWithServices { private readonly ITextSerializer _serializer; + private readonly JsonSerializerOptions _jsonOptions; public DataDictionaryTests(ITestOutputHelper output) : base(output) { _serializer = GetService(); + _jsonOptions = GetService(); } [Fact] @@ -28,7 +31,7 @@ public void GetValue_DirectUserInfoType_ReturnsTypedValue() var data = new DataDictionary { { "user", userInfo } }; // Act - var result = data.GetValue("user"); + var result = data.GetValue("user", _jsonOptions); // Assert Assert.NotNull(result); @@ -43,7 +46,7 @@ public void GetValue_DirectStringType_ReturnsStringValue() var data = new DataDictionary { { "version", "1.0.0" } }; // Act - string? result = data.GetValue("version"); + string? result = data.GetValue("version", _jsonOptions); // Assert Assert.Equal("1.0.0", result); @@ -56,22 +59,21 @@ public void GetValue_DirectIntType_ReturnsIntValue() var data = new DataDictionary { { "count", 42 } }; // Act - int result = data.GetValue("count"); + int result = data.GetValue("count", _jsonOptions); // Assert Assert.Equal(42, result); } - [Fact] public void GetValue_JObjectWithUserInfo_ReturnsTypedUserInfo() { - // Arrange + // Arrange - JObject comes from Elasticsearch via NEST/JSON.NET var jObject = JObject.FromObject(new { Identity = "jobj@test.com", Name = "JObject User" }); var data = new DataDictionary { { "user", jObject } }; // Act - var result = data.GetValue("user"); + var result = data.GetValue("user", _jsonOptions); // Assert Assert.NotNull(result); @@ -95,7 +97,7 @@ public void GetValue_JObjectWithError_ReturnsTypedError() var data = new DataDictionary { { "@error", jObject } }; // Act - var result = data.GetValue("@error"); + var result = data.GetValue("@error", _jsonOptions); // Assert Assert.NotNull(result); @@ -121,7 +123,7 @@ public void GetValue_JObjectWithRequestInfo_ReturnsTypedRequestInfo() var data = new DataDictionary { { "@request", jObject } }; // Act - var result = data.GetValue("@request"); + var result = data.GetValue("@request", _jsonOptions); // Assert Assert.NotNull(result); @@ -147,7 +149,7 @@ public void GetValue_JObjectWithEnvironmentInfo_ReturnsTypedEnvironmentInfo() var data = new DataDictionary { { "@environment", jObject } }; // Act - var result = data.GetValue("@environment"); + var result = data.GetValue("@environment", _jsonOptions); // Assert Assert.NotNull(result); @@ -174,7 +176,7 @@ public void GetValue_JObjectWithNestedError_ReturnsNestedHierarchy() var data = new DataDictionary { { "@error", jObject } }; // Act - var result = data.GetValue("@error"); + var result = data.GetValue("@error", _jsonOptions); // Assert Assert.NotNull(result); @@ -183,7 +185,6 @@ public void GetValue_JObjectWithNestedError_ReturnsNestedHierarchy() Assert.Equal("Inner JObject error", result.Inner.Message); } - [Fact] public void GetValue_JsonStringWithUserInfo_ReturnsTypedUserInfo() { @@ -193,7 +194,7 @@ public void GetValue_JsonStringWithUserInfo_ReturnsTypedUserInfo() var data = new DataDictionary { { "user", json } }; // Act - var result = data.GetValue("user"); + var result = data.GetValue("user", _jsonOptions); // Assert Assert.NotNull(result); @@ -210,7 +211,7 @@ public void GetValue_JsonStringWithError_ReturnsTypedError() var data = new DataDictionary { { "@error", json } }; // Act - var result = data.GetValue("@error"); + var result = data.GetValue("@error", _jsonOptions); // Assert Assert.NotNull(result); @@ -221,13 +222,13 @@ public void GetValue_JsonStringWithError_ReturnsTypedError() [Fact] public void GetValue_JsonStringWithRequestInfo_ReturnsTypedRequestInfo() { - // Arrange - Using PascalCase as JsonConvert.DeserializeObject uses default settings + // Arrange /* language=json */ - const string json = """{"HttpMethod":"POST","Path":"/api/events","Host":"api.example.com","Port":443,"IsSecure":true}"""; + const string json = """{"http_method":"POST","path":"/api/events","host":"api.example.com","port":443,"is_secure":true}"""; var data = new DataDictionary { { "@request", json } }; // Act - var result = data.GetValue("@request"); + var result = data.GetValue("@request", _jsonOptions); // Assert Assert.NotNull(result); @@ -238,13 +239,13 @@ public void GetValue_JsonStringWithRequestInfo_ReturnsTypedRequestInfo() [Fact] public void GetValue_JsonStringWithEnvironmentInfo_ReturnsTypedEnvironmentInfo() { - // Arrange - Using PascalCase as JsonConvert.DeserializeObject uses default settings + // Arrange /* language=json */ - const string json = """{"MachineName":"STRING-MACHINE","ProcessorCount":16}"""; + const string json = """{"machine_name":"STRING-MACHINE","processor_count":16}"""; var data = new DataDictionary { { "@environment", json } }; // Act - var result = data.GetValue("@environment"); + var result = data.GetValue("@environment", _jsonOptions); // Assert Assert.NotNull(result); @@ -261,7 +262,7 @@ public void GetValue_JsonStringWithSimpleError_ReturnsTypedSimpleError() var data = new DataDictionary { { "@simple_error", json } }; // Act - var result = data.GetValue("@simple_error"); + var result = data.GetValue("@simple_error", _jsonOptions); // Assert Assert.NotNull(result); @@ -278,7 +279,7 @@ public void GetValue_JsonStringWithNestedError_ReturnsNestedHierarchy() var data = new DataDictionary { { "@error", json } }; // Act - var result = data.GetValue("@error"); + var result = data.GetValue("@error", _jsonOptions); // Assert Assert.NotNull(result); @@ -294,13 +295,12 @@ public void GetValue_NonJsonString_ReturnsNull() var data = new DataDictionary { { "text", "not json" } }; // Act - var result = data.GetValue("text"); + var result = data.GetValue("text", _jsonOptions); // Assert Assert.Null(result); } - [Fact] public void GetValue_MissingKey_ThrowsKeyNotFoundException() { @@ -308,7 +308,7 @@ public void GetValue_MissingKey_ThrowsKeyNotFoundException() var data = new DataDictionary(); // Act & Assert - Assert.Throws(() => data.GetValue("nonexistent")); + Assert.Throws(() => data.GetValue("nonexistent", _jsonOptions)); } [Fact] @@ -318,7 +318,7 @@ public void GetValue_NullValue_ReturnsNull() var data = new DataDictionary { { "nullable", null! } }; // Act - var result = data.GetValue("nullable"); + var result = data.GetValue("nullable", _jsonOptions); // Assert Assert.Null(result); @@ -331,7 +331,7 @@ public void GetValue_IncompatibleType_ReturnsNull() var data = new DataDictionary { { "number", 42 } }; // Act - var result = data.GetValue("number"); + var result = data.GetValue("number", _jsonOptions); // Assert Assert.Null(result); @@ -340,22 +340,21 @@ public void GetValue_IncompatibleType_ReturnsNull() [Fact] public void GetValue_MalformedJsonString_ReturnsDefaultProperties() { - // Arrange - JSON string with properties that don't match UserInfo + // Arrange /* language=json */ const string json = """{"foo":"bar"}"""; var data = new DataDictionary { { "user", json } }; // Act - var result = data.GetValue("user"); + var result = data.GetValue("user", _jsonOptions); // Assert Assert.NotNull(result); Assert.Null(result.Identity); } - [Fact] - public void Deserialize_DataDictionaryWithUserInfo_PreservesTypedData() + public void Deserialize_DataDictionaryWithUserInfoAfterRoundTrip_PreservesTypedData() { // Arrange var data = new DataDictionary @@ -370,14 +369,14 @@ public void Deserialize_DataDictionaryWithUserInfo_PreservesTypedData() // Assert Assert.NotNull(deserialized); Assert.True(deserialized.ContainsKey("@user")); - var userInfo = deserialized.GetValue("@user"); + var userInfo = deserialized.GetValue("@user", _jsonOptions); Assert.NotNull(userInfo); Assert.Equal("user@test.com", userInfo.Identity); Assert.Equal("Test User", userInfo.Name); } [Fact] - public void Deserialize_DataDictionaryWithMixedTypes_PreservesAllTypes() + public void Deserialize_DataDictionaryWithMixedTypesAfterRoundTrip_PreservesAllTypes() { // Arrange var data = new DataDictionary @@ -395,16 +394,15 @@ public void Deserialize_DataDictionaryWithMixedTypes_PreservesAllTypes() // Assert Assert.NotNull(deserialized); Assert.Equal("hello", deserialized["string_value"]); - Assert.Equal(42L, deserialized["int_value"]); + Assert.Equal(42, deserialized["int_value"]); Assert.True(deserialized["bool_value"] as bool?); } [Fact] - public void Deserialize_EmptyDataDictionary_PreservesEmptyState() + public void Deserialize_EmptyDataDictionaryAfterRoundTrip_PreservesEmptyState() { // Arrange var data = new DataDictionary(); - /* language=json */ const string expectedJson = """{}"""; @@ -418,4 +416,249 @@ public void Deserialize_EmptyDataDictionary_PreservesEmptyState() Assert.Empty(deserialized); } + [Fact] + public void Deserialize_UserInfoAfterRoundTrip_PreservesAllProperties() + { + // Arrange + var original = new UserInfo("stj@test.com", "STJ Test User"); + var data = new DataDictionary { { "@user", original } }; + + // Act + string? json = _serializer.SerializeToString(data); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + var result = deserialized.GetValue("@user", _jsonOptions); + Assert.NotNull(result); + Assert.Equal("stj@test.com", result.Identity); + Assert.Equal("STJ Test User", result.Name); + } + + [Fact] + public void Deserialize_ErrorAfterRoundTrip_PreservesComplexStructure() + { + // Arrange + var original = new Error + { + Message = "Test Exception", + Type = "System.InvalidOperationException", + Code = "ERR001", + StackTrace = + [ + new StackFrame + { + Name = "TestMethod", + DeclaringNamespace = "TestNamespace", + DeclaringType = "TestClass", + LineNumber = 42 + } + ] + }; + var data = new DataDictionary { { "@error", original } }; + + // Act + string? json = _serializer.SerializeToString(data); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + var result = deserialized.GetValue("@error", _jsonOptions); + Assert.NotNull(result); + Assert.Equal("Test Exception", result.Message); + Assert.Equal("System.InvalidOperationException", result.Type); + Assert.Equal("ERR001", result.Code); + Assert.NotNull(result.StackTrace); + Assert.Single(result.StackTrace); + Assert.Equal("TestMethod", result.StackTrace[0].Name); + Assert.Equal(42, result.StackTrace[0].LineNumber); + } + + [Fact] + public void Deserialize_RequestInfoAfterRoundTrip_PreservesAllProperties() + { + // Arrange + var original = new RequestInfo + { + HttpMethod = "POST", + Path = "/api/events", + Host = "api.example.com", + Port = 443, + IsSecure = true, + ClientIpAddress = "192.168.1.1" + }; + var data = new DataDictionary { { "@request", original } }; + + // Act + string? json = _serializer.SerializeToString(data); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + var result = deserialized.GetValue("@request", _jsonOptions); + Assert.NotNull(result); + Assert.Equal("POST", result.HttpMethod); + Assert.Equal("/api/events", result.Path); + Assert.Equal("api.example.com", result.Host); + Assert.Equal(443, result.Port); + Assert.True(result.IsSecure); + Assert.Equal("192.168.1.1", result.ClientIpAddress); + } + + [Fact] + public void Deserialize_EnvironmentInfoAfterRoundTrip_PreservesAllProperties() + { + // Arrange + var original = new EnvironmentInfo + { + MachineName = "TEST-MACHINE", + ProcessorCount = 16, + TotalPhysicalMemory = 32000000000L, + OSName = "Windows", + OSVersion = "10.0.19041" + }; + var data = new DataDictionary { { "@environment", original } }; + + // Act + string? json = _serializer.SerializeToString(data); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + var result = deserialized.GetValue("@environment", _jsonOptions); + Assert.NotNull(result); + Assert.Equal("TEST-MACHINE", result.MachineName); + Assert.Equal(16, result.ProcessorCount); + Assert.Equal(32000000000L, result.TotalPhysicalMemory); + Assert.Equal("Windows", result.OSName); + } + + [Fact] + public void Deserialize_NestedErrorAfterRoundTrip_PreservesInnerError() + { + // Arrange + var original = new Error + { + Message = "Outer exception", + Type = "OuterException", + Inner = new InnerError + { + Message = "Inner exception", + Type = "InnerException" + } + }; + var data = new DataDictionary { { "@error", original } }; + + // Act + string? json = _serializer.SerializeToString(data); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + var result = deserialized.GetValue("@error", _jsonOptions); + Assert.NotNull(result); + Assert.Equal("Outer exception", result.Message); + Assert.NotNull(result.Inner); + Assert.Equal("Inner exception", result.Inner.Message); + Assert.Equal("InnerException", result.Inner.Type); + } + + [Fact] + public void Deserialize_MixedDataTypesAfterRoundTrip_PreservesAllTypes() + { + // Arrange + var data = new DataDictionary + { + { "@user", new UserInfo("user@test.com", "Test") }, + { "@version", "1.0.0" }, + { "count", 42 }, + { "enabled", true } + }; + + // Act + string? json = _serializer.SerializeToString(data); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + + var userInfo = deserialized.GetValue("@user", _jsonOptions); + Assert.NotNull(userInfo); + Assert.Equal("user@test.com", userInfo.Identity); + + Assert.Equal("1.0.0", deserialized["@version"]); + Assert.Equal(42, deserialized["count"]); + Assert.True(deserialized["enabled"] as bool?); + } + + [Fact] + public void Deserialize_NestedDataDictionaryAfterRoundTrip_PreservesNestedData() + { + // Arrange + var original = new UserInfo("user@test.com", "Test User") + { + Data = new DataDictionary + { + { "custom_field", "custom_value" }, + { "score", 100 } + } + }; + var data = new DataDictionary { { "@user", original } }; + + // Act + string? json = _serializer.SerializeToString(data); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + var result = deserialized.GetValue("@user", _jsonOptions); + Assert.NotNull(result); + Assert.Equal("user@test.com", result.Identity); + Assert.NotNull(result.Data); + Assert.Equal("custom_value", result.Data["custom_field"]); + Assert.Equal(100, result.Data["score"]); + } + + [Fact] + public void GetValue_DictionaryOfStringObject_DeserializesToTypedObject() + { + // Arrange - Simulates what ObjectToInferredTypesConverter produces after deserialization + var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "identity", "dict@test.com" }, + { "name", "Dictionary User" } + }; + var data = new DataDictionary { { "@user", dictionary } }; + + // Act + var result = data.GetValue("@user", _jsonOptions); + + // Assert + Assert.NotNull(result); + Assert.Equal("dict@test.com", result.Identity); + Assert.Equal("Dictionary User", result.Name); + } + + [Fact] + public void GetValue_ListOfObjects_DeserializesToTypedCollection() + { + // Arrange - Simulates array from ObjectToInferredTypesConverter + var list = new List + { + new Dictionary { { "name", "Frame1" }, { "line_number", 10L } }, + new Dictionary { { "name", "Frame2" }, { "line_number", 20L } } + }; + var data = new DataDictionary { { "frames", list } }; + + // Act + var result = data.GetValue>("frames", _jsonOptions); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Equal("Frame1", result[0].Name); + Assert.Equal(10, result[0].LineNumber); + Assert.Equal("Frame2", result[1].Name); + Assert.Equal(20, result[1].LineNumber); + } } diff --git a/tests/Exceptionless.Tests/Serializer/Models/PersistentEventSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/PersistentEventSerializerTests.cs index 35cb4b63b8..5dafe0748c 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/PersistentEventSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/PersistentEventSerializerTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; @@ -14,11 +15,13 @@ namespace Exceptionless.Tests.Serializer.Models; public class PersistentEventSerializerTests : TestWithServices { private readonly ITextSerializer _serializer; + private readonly JsonSerializerOptions _jsonOptions; private static readonly DateTimeOffset FixedDate = new(2024, 1, 15, 12, 0, 0, TimeSpan.Zero); public PersistentEventSerializerTests(ITestOutputHelper output) : base(output) { _serializer = GetService(); + _jsonOptions = GetService(); TimeProvider.SetUtcNow(FixedDate); } @@ -103,7 +106,7 @@ public void Deserialize_EventWithUserInfo_PreservesTypedUserInfo() // Assert Assert.NotNull(deserialized); - var userInfo = deserialized.GetUserIdentity(); + var userInfo = deserialized.GetUserIdentity(_jsonOptions); Assert.NotNull(userInfo); Assert.Equal("user@example.com", userInfo.Identity); Assert.Equal("Test User", userInfo.Name); @@ -144,7 +147,7 @@ public void Deserialize_EventWithError_PreservesTypedError() // Assert Assert.NotNull(deserialized); - var error = deserialized.GetError(); + var error = deserialized.GetError(_jsonOptions); Assert.NotNull(error); Assert.Equal("Test exception", error.Message); Assert.Equal("System.InvalidOperationException", error.Type); @@ -181,7 +184,7 @@ public void Deserialize_EventWithRequestInfo_PreservesTypedRequestInfo() // Assert Assert.NotNull(deserialized); - var request = deserialized.GetRequestInfo(); + var request = deserialized.GetRequestInfo(_jsonOptions); Assert.NotNull(request); Assert.Equal("POST", request.HttpMethod); Assert.Equal("/api/events", request.Path); @@ -213,7 +216,7 @@ public void Deserialize_EventWithEnvironmentInfo_PreservesTypedEnvironmentInfo() // Assert Assert.NotNull(deserialized); - var env = deserialized.GetEnvironmentInfo(); + var env = deserialized.GetEnvironmentInfo(_jsonOptions); Assert.NotNull(env); Assert.Equal("PROD-SERVER-01", env.MachineName); Assert.Equal(8, env.ProcessorCount); @@ -268,9 +271,9 @@ public void Deserialize_EventWithAllKnownDataKeys_PreservesAllTypes() // Assert Assert.NotNull(deserialized); - Assert.NotNull(deserialized.GetUserIdentity()); - Assert.NotNull(deserialized.GetRequestInfo()); - Assert.NotNull(deserialized.GetEnvironmentInfo()); + Assert.NotNull(deserialized.GetUserIdentity(_jsonOptions)); + Assert.NotNull(deserialized.GetRequestInfo(_jsonOptions)); + Assert.NotNull(deserialized.GetEnvironmentInfo(_jsonOptions)); Assert.Equal("1.0.0", deserialized.GetVersion()); Assert.Equal("Error", deserialized.GetLevel()); } @@ -326,7 +329,7 @@ public void Deserialize_JsonWithTypedUserData_RetrievesTypedUserInfo() // Assert Assert.NotNull(ev); - var userInfo = ev.GetUserIdentity(); + var userInfo = ev.GetUserIdentity(_jsonOptions); Assert.NotNull(userInfo); Assert.Equal("parsed@example.com", userInfo.Identity); Assert.Equal("Parsed User", userInfo.Name); diff --git a/tests/Exceptionless.Tests/Serializer/Models/RequestInfoSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/RequestInfoSerializerTests.cs index 10d73f2332..03f7f29d89 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/RequestInfoSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/RequestInfoSerializerTests.cs @@ -143,8 +143,7 @@ public void Deserialize_RequestInfoWithCookies_PreservesCookies() var deserialized = _serializer.Deserialize(json); // Assert - Assert.NotNull(deserialized); - Assert.NotNull(deserialized.Cookies); + Assert.NotNull(deserialized?.Cookies); Assert.Equal("abc123", deserialized.Cookies["session_id"]); Assert.Equal("dark", deserialized.Cookies["theme"]); } @@ -169,11 +168,11 @@ public void Deserialize_RequestInfoWithPostData_PreservesPostData() var deserialized = _serializer.Deserialize(json); // Assert - Assert.NotNull(deserialized); - Assert.NotNull(deserialized.PostData); - // PostData deserializes as JObject, verify it contains expected values - string? postData = deserialized.PostData.ToString(); - Assert.Contains("testuser", postData); + Assert.NotNull(deserialized?.PostData); + var postData = deserialized.PostData as IDictionary; + Assert.NotNull(postData); + Assert.Equal("testuser", postData["username"]); + Assert.Equal("true", postData["remember_me"]); } [Fact] diff --git a/tests/Exceptionless.Tests/Serializer/ObjectToInferredTypesConverterTests.cs b/tests/Exceptionless.Tests/Serializer/ObjectToInferredTypesConverterTests.cs new file mode 100644 index 0000000000..3708a85ab5 --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/ObjectToInferredTypesConverterTests.cs @@ -0,0 +1,652 @@ +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Serializer; + +/// +/// Tests for ObjectToInferredTypesConverter. +/// Validates that object-typed properties are correctly deserialized to native .NET types +/// instead of JsonElement, enabling proper GetValue behavior. +/// +public class ObjectToInferredTypesConverterTests : TestWithServices +{ + private readonly ITextSerializer _serializer; + + public ObjectToInferredTypesConverterTests(ITestOutputHelper output) : base(output) + { + _serializer = GetService(); + } + + [Fact] + public void Read_TrueBoolean_ReturnsNativeBool() + { + // Arrange + /* language=json */ + const string json = """{"value": true}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.True(result.ContainsKey("value")); + Assert.IsType(result["value"]); + Assert.True((bool)result["value"]!); + } + + [Fact] + public void Read_FalseBoolean_ReturnsNativeBool() + { + // Arrange + /* language=json */ + const string json = """{"value": false}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.IsType(result["value"]); + Assert.False((bool)result["value"]!); + } + + [Fact] + public void Read_IntegerNumber_ReturnsLong() + { + // Arrange + /* language=json */ + const string json = """{"count": 42}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.IsType(result["count"]); + Assert.Equal(42, result["count"]); + } + + [Fact] + public void Read_LargeInteger_ReturnsLong() + { + // Arrange + /* language=json */ + const string json = """{"bigNumber": 9223372036854775807}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.IsType(result["bigNumber"]); + Assert.Equal(Int64.MaxValue, result["bigNumber"]); + } + + [Fact] + public void Read_NegativeInteger_ReturnsLong() + { + // Arrange + /* language=json */ + const string json = """{"negative": -12345}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.IsType(result["negative"]); + Assert.Equal(-12345, result["negative"]); + } + + [Fact] + public void Read_DecimalNumber_ReturnsDouble() + { + // Arrange + /* language=json */ + const string json = """{"price": 99.95}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.IsType(result["price"]); + Assert.Equal(99.95m, result["price"]); + } + + [Fact] + public void Read_ScientificNotation_ReturnsDouble() + { + // Arrange + /* language=json */ + const string json = """{"value": 1.23e10}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.IsType(result["value"]); + Assert.Equal(12300000000m, (decimal)result["value"]!); + } + + [Fact] + public void Read_PlainString_ReturnsString() + { + // Arrange + /* language=json */ + const string json = """{"name": "test value"}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.IsType(result["name"]); + Assert.Equal("test value", result["name"]); + } + + [Fact] + public void Read_EmptyString_ReturnsEmptyString() + { + // Arrange + /* language=json */ + const string json = """{"empty": ""}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.IsType(result["empty"]); + Assert.Equal(String.Empty, result["empty"]); + } + + [Fact] + public void Read_NullValue_ReturnsNull() + { + // Arrange + /* language=json */ + const string json = """{"nothing": null}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.True(result.ContainsKey("nothing")); + Assert.Null(result["nothing"]); + } + + [Fact] + public void Read_Iso8601DateTime_ReturnsDateTimeOffset() + { + // Arrange + /* language=json */ + const string json = """{"timestamp": "2024-01-15T12:30:45.123Z"}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.IsType(result["timestamp"]); + var dateTime = (DateTimeOffset)result["timestamp"]!; + Assert.Equal(2024, dateTime.Year); + Assert.Equal(1, dateTime.Month); + Assert.Equal(15, dateTime.Day); + Assert.Equal(12, dateTime.Hour); + Assert.Equal(30, dateTime.Minute); + Assert.Equal(45, dateTime.Second); + } + + [Fact] + public void Read_Iso8601WithOffset_ReturnsDateTimeOffsetWithOffset() + { + // Arrange + /* language=json */ + const string json = """{"timestamp": "2024-01-15T12:30:45+05:30"}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.IsType(result["timestamp"]); + var dateTime = (DateTimeOffset)result["timestamp"]!; + Assert.Equal(TimeSpan.FromHours(5.5), dateTime.Offset); + } + + [Fact] + public void Read_DateOnly_ReturnsDateTimeOffset() + { + // Arrange + /* language=json */ + const string json = """{"date": "2024-01-15"}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.IsType(result["date"]); + } + + [Fact] + public void Read_NonDateString_ReturnsString() + { + // Arrange + /* language=json */ + const string json = """{"notADate": "hello world"}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.IsType(result["notADate"]); + Assert.Equal("hello world", result["notADate"]); + } + + [Fact] + public void Read_NestedObject_ReturnsDictionary() + { + // Arrange + /* language=json */ + const string json = """{"user": {"identity": "test@example.com", "name": "Test User"}}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.IsType>(result["user"]); + + var user = (Dictionary)result["user"]!; + Assert.Equal("test@example.com", user["identity"]); + Assert.Equal("Test User", user["name"]); + } + + [Fact] + public void Read_DeeplyNestedObject_ReturnsDictionaryHierarchy() + { + // Arrange + /* language=json */ + const string json = """ + { + "level1": { + "level2": { + "level3": { + "value": "deep" + } + } + } + } + """; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + var level1 = Assert.IsType>(result["level1"]); + var level2 = Assert.IsType>(level1["level2"]); + var level3 = Assert.IsType>(level2["level3"]); + Assert.Equal("deep", level3["value"]); + } + + [Fact] + public void Read_EmptyObject_ReturnsEmptyDictionary() + { + // Arrange + /* language=json */ + const string json = """{"empty": {}}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + var empty = Assert.IsType>(result["empty"]); + Assert.Empty(empty); + } + + [Fact] + public void Read_ObjectWithMixedTypes_ReturnsCorrectTypes() + { + // Arrange + /* language=json */ + const string json = """ + { + "data": { + "count": 42, + "name": "test", + "active": true, + "nullable": null, + "timestamp": "2024-01-15T12:00:00Z" + } + } + """; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + var data = Assert.IsType>(result["data"]); + + Assert.IsType(data["count"]); + Assert.IsType(data["name"]); + Assert.IsType(data["active"]); + Assert.Null(data["nullable"]); + Assert.IsType(data["timestamp"]); + } + + [Fact] + public void Read_ArrayOfStrings_ReturnsList() + { + // Arrange + /* language=json */ + const string json = """{"tags": ["one", "two", "three"]}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + var tags = Assert.IsType>(result["tags"]); + Assert.Equal(3, tags.Count); + Assert.Equal("one", tags[0]); + Assert.Equal("two", tags[1]); + Assert.Equal("three", tags[2]); + } + + [Fact] + public void Read_ArrayOfNumbers_ReturnsListOfLongs() + { + // Arrange + /* language=json */ + const string json = """{"numbers": [1, 2, 3, 4, 5]}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + var numbers = Assert.IsType>(result["numbers"]); + Assert.All(numbers, n => Assert.IsType(n)); + Assert.Equal([1, 2, 3, 4, 5], numbers); + } + + [Fact] + public void Read_ArrayOfMixedTypes_ReturnsListWithCorrectTypes() + { + // Arrange + /* language=json */ + const string json = """{"mixed": ["string", 42, true, null, 3.14]}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + var mixed = Assert.IsType>(result["mixed"]); + Assert.Equal(5, mixed.Count); + Assert.IsType(mixed[0]); + Assert.IsType(mixed[1]); + Assert.IsType(mixed[2]); + Assert.Null(mixed[3]); + Assert.IsType(mixed[4]); + } + + [Fact] + public void Read_ArrayOfObjects_ReturnsListOfDictionaries() + { + // Arrange + /* language=json */ + const string json = """ + { + "items": [ + {"name": "first", "value": 1}, + {"name": "second", "value": 2} + ] + } + """; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + var items = Assert.IsType>(result["items"]); + Assert.Equal(2, items.Count); + + var first = Assert.IsType>(items[0]); + Assert.Equal("first", first["name"]); + Assert.Equal(1, first["value"]); + + var second = Assert.IsType>(items[1]); + Assert.Equal("second", second["name"]); + Assert.Equal(2, second["value"]); + } + + [Fact] + public void Read_NestedArrays_ReturnsNestedLists() + { + // Arrange + /* language=json */ + const string json = """{"matrix": [[1, 2], [3, 4], [5, 6]]}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + var matrix = Assert.IsType>(result["matrix"]); + Assert.Equal(3, matrix.Count); + + var row1 = Assert.IsType>(matrix[0]); + Assert.Equal([1, 2], row1); + } + + [Fact] + public void Read_EmptyArray_ReturnsEmptyList() + { + // Arrange + /* language=json */ + const string json = """{"empty": []}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + var empty = Assert.IsType>(result["empty"]); + Assert.Empty(empty); + } + + [Fact] + public void Read_ObjectWithVariedCasing_SupportsCaseInsensitiveAccess() + { + // Arrange + /* language=json */ + const string json = """{"data": {"UserName": "test", "user_email": "test@example.com", "userId": 123}}"""; + + // Act + var wrapper = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(wrapper); + var result = Assert.IsType>(wrapper["data"]); + + // Nested dictionaries created by our converter ARE case-insensitive + Assert.Equal("test", result["username"]); + Assert.Equal("test@example.com", result["USER_EMAIL"]); + Assert.Equal(123, result["USERID"]); + } + + [Fact] + public void Write_DictionaryWithPrimitives_SerializesCorrectly() + { + // Arrange + var data = new Dictionary + { + ["name"] = "test", + ["count"] = 42L, + ["active"] = true, + ["nothing"] = null + }; + + // Act + string? json = _serializer.SerializeToString(data); + + // Assert + Assert.NotNull(json); + Assert.Contains("\"name\":\"test\"", json); + Assert.Contains("\"count\":42", json); + Assert.Contains("\"active\":true", json); + Assert.Contains("\"nothing\":null", json); + } + + [Fact] + public void Write_NestedDictionary_SerializesCorrectly() + { + // Arrange + var data = new Dictionary + { + ["outer"] = new Dictionary + { + ["inner"] = "value" + } + }; + + // Act + string? json = _serializer.SerializeToString(data); + + // Assert + Assert.NotNull(json); + Assert.Contains("\"outer\":{\"inner\":\"value\"}", json); + } + + [Fact] + public void Write_ListOfValues_SerializesCorrectly() + { + // Arrange + var data = new Dictionary + { + ["items"] = new List { "a", 1L, true } + }; + + // Act + string? json = _serializer.SerializeToString(data); + + // Assert + Assert.NotNull(json); + Assert.Contains("\"items\":[\"a\",1,true]", json); + } + + [Fact] + public void Deserialize_ComplexStructureAfterRoundTrip_PreservesData() + { + // Arrange + var original = new Dictionary + { + ["name"] = "test", + ["count"] = 42L, + ["active"] = true, + ["price"] = 99.95, + ["tags"] = new List { "one", "two" }, + ["nested"] = new Dictionary + { + ["inner"] = "value", + ["number"] = 123L + } + }; + + // Act + string? json = _serializer.SerializeToString(original); + var roundTripped = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(roundTripped); + Assert.Equal("test", roundTripped["name"]); + Assert.Equal(42, roundTripped["count"]); + Assert.True((bool)roundTripped["active"]!); + Assert.Equal(99.95m, (decimal)roundTripped["price"]!); + + var tags = Assert.IsType>(roundTripped["tags"]); + Assert.Equal(2, tags.Count); + + var nested = Assert.IsType>(roundTripped["nested"]); + Assert.Equal("value", nested["inner"]); + Assert.Equal(123, nested["number"]); + } + + [Fact] + public void Read_SpecialCharactersInString_PreservesCharacters() + { + // Arrange + /* language=json */ + const string json = """{"text": "hello\nworld\ttab\"quote"}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.Equal("hello\nworld\ttab\"quote", result["text"]); + } + + [Fact] + public void Read_UnicodeString_PreservesUnicode() + { + // Arrange + /* language=json */ + const string json = """{"text": "Hello 世界 🌍"}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.Equal("Hello 世界 🌍", result["text"]); + } + + [Fact] + public void Read_VeryLongString_PreservesContent() + { + // Arrange + string longString = new('x', 10000); + string json = $$"""{"long": "{{longString}}"}"""; + + // Act + var result = _serializer.Deserialize>(json); + + // Assert + Assert.NotNull(result); + Assert.Equal(longString, result["long"]); + } + + [Fact] + public void Read_NumberAtInt64Boundary_HandlesCorrectly() + { + // Arrange + /* language=json */ + const string json1 = """{"value": 9223372036854775807}"""; + /* language=json */ + const string json2 = """{"value": 9223372036854775808}"""; + + // Act + var result1 = _serializer.Deserialize>(json1); + var result2 = _serializer.Deserialize>(json2); + + // Assert - Number that fits in long + Assert.NotNull(result1); + Assert.IsType(result1["value"]); + Assert.Equal(Int64.MaxValue, result1["value"]); + + // Assert - Number exceeding long.MaxValue becomes double + Assert.NotNull(result2); + Assert.IsType(result2["value"]); + } +} diff --git a/tests/Exceptionless.Tests/Utility/DataBuilder.cs b/tests/Exceptionless.Tests/Utility/DataBuilder.cs index d5d667181b..d5997b6dca 100644 --- a/tests/Exceptionless.Tests/Utility/DataBuilder.cs +++ b/tests/Exceptionless.Tests/Utility/DataBuilder.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Plugins.Formatting; @@ -37,6 +38,7 @@ public class EventDataBuilder private readonly FormattingPluginManager _formattingPluginManager; private readonly ISerializer _serializer; private readonly TimeProvider _timeProvider; + private readonly JsonSerializerOptions _jsonOptions; private readonly ICollection> _stackMutations; private int _additionalEventsToCreate = 0; private readonly PersistentEvent _event = new(); @@ -44,11 +46,12 @@ public class EventDataBuilder private EventDataBuilder? _stackEventBuilder; private bool _isFirstOccurrenceSet = false; - public EventDataBuilder(FormattingPluginManager formattingPluginManager, ISerializer serializer, TimeProvider timeProvider) + public EventDataBuilder(FormattingPluginManager formattingPluginManager, ISerializer serializer, JsonSerializerOptions jsonOptions, TimeProvider timeProvider) { _stackMutations = new List>(); _formattingPluginManager = formattingPluginManager; _serializer = serializer; + _jsonOptions = jsonOptions; _timeProvider = timeProvider; } @@ -531,7 +534,7 @@ public EventDataBuilder Snooze(DateTime? snoozeUntil = null) if (_stack.FirstOccurrence < _event.Date) _event.IsFirstOccurrence = false; - var msi = _event.GetManualStackingInfo(); + var msi = _event.GetManualStackingInfo(_jsonOptions); if (msi is not null) { _stack.Title = msi.Title!; diff --git a/tests/http/admin.http b/tests/http/admin.http index a1f9aa331c..e0f7742354 100644 --- a/tests/http/admin.http +++ b/tests/http/admin.http @@ -1,4 +1,5 @@ -@apiUrl = http://localhost:5200/api/v2 +@url = http://localhost:5200 +@apiUrl = {url}/api/v2 @email = test@localhost @password = tester @organizationId = 537650f3b77efe23a47914f3 @@ -22,6 +23,10 @@ Content-Type: application/json GET {{apiUrl}}/users/me Authorization: Bearer {{token}} +### Get OpenApi schema +# @name openapi +GET {{url}}/docs/v2/openapi.json + ### @userId = {{currentUser.response.body.$.id}}