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/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/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.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..c0f021b670 100644 --- a/src/Exceptionless.Web/Controllers/StackController.cs +++ b/src/Exceptionless.Web/Controllers/StackController.cs @@ -21,7 +21,7 @@ using McSherry.SemanticVersioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json.Linq; +using System.Text.Json; 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..ea4dfd80bb 100644 --- a/src/Exceptionless.Web/Controllers/WebHookController.cs +++ b/src/Exceptionless.Web/Controllers/WebHookController.cs @@ -11,7 +11,7 @@ using Foundatio.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json.Linq; +using System.Text.Json; 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 78b2c2299b..93e431184d 100644 --- a/src/Exceptionless.Web/Exceptionless.Web.csproj +++ b/src/Exceptionless.Web/Exceptionless.Web.csproj @@ -20,6 +20,12 @@ + + + + + + @@ -35,6 +41,7 @@ + diff --git a/src/Exceptionless.Web/Hubs/WebSocketConnectionManager.cs b/src/Exceptionless.Web/Hubs/WebSocketConnectionManager.cs index 75f5ae118f..f30a71235b 100644 --- a/src/Exceptionless.Web/Hubs/WebSocketConnectionManager.cs +++ b/src/Exceptionless.Web/Hubs/WebSocketConnectionManager.cs @@ -1,8 +1,9 @@ using System.Collections.Concurrent; using System.Net.WebSockets; using System.Text; +using System.Text.Json; using Exceptionless.Core; -using Newtonsoft.Json; +using Exceptionless.Web.Utility; namespace Exceptionless.Web.Hubs; @@ -11,12 +12,15 @@ 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 JsonSerializerOptions _serializerOptions; private readonly ILogger _logger; - public WebSocketConnectionManager(AppOptions options, JsonSerializerSettings serializerSettings, ILoggerFactory loggerFactory) + public WebSocketConnectionManager(AppOptions options, ILoggerFactory loggerFactory) { - _serializerSettings = serializerSettings; + _serializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = LowerCaseUnderscoreNamingPolicy.Instance + }; _logger = loggerFactory.CreateLogger(); if (!options.EnableWebSockets) return; @@ -119,7 +123,7 @@ private Task SendMessageAsync(WebSocket socket, object message) if (!CanSendWebSocketMessage(socket)) return Task.CompletedTask; - string serializedMessage = JsonConvert.SerializeObject(message, _serializerSettings); + string serializedMessage = JsonSerializer.Serialize(message, _serializerOptions); Task.Factory.StartNew(async () => { if (!CanSendWebSocketMessage(socket)) diff --git a/src/Exceptionless.Web/Models/Auth/ExternalAuthInfo.cs b/src/Exceptionless.Web/Models/Auth/ExternalAuthInfo.cs index b23d1b2acf..ea39e35fc4 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] + [JsonPropertyName("clientId")] public required string ClientId { get; init; } [Required] + [JsonPropertyName("code")] public required string Code { get; init; } [Required] + [JsonPropertyName("redirectUri")] public required string RedirectUri { get; init; } + [JsonPropertyName("inviteToken")] public string? InviteToken { get; init; } } diff --git a/src/Exceptionless.Web/Startup.cs b/src/Exceptionless.Web/Startup.cs index 54abf4b644..9e1f241f61 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,27 @@ ApplicationException applicationException when applicationException.Message.Cont app.UseEndpoints(endpoints => { + endpoints.MapOpenApi("/docs/v2/openapi.json"); + endpoints.MapScalarApiReference("docs", o => + { + o.WithTitle("Exceptionless API") + .WithTheme(ScalarTheme.Default) + .AddHttpAuthentication("BasicAuth", auth => + { + auth.Username = "your-username"; + auth.Password = "your-password"; + }) + .AddHttpAuthentication("BearerAuth", auth => + { + auth.Token = "apikey"; + }) + .AddApiKeyAuthentication("ApiKey", apiKey => + { + apiKey.Value = "access_token"; + }) + .AddPreferredSecuritySchemes("BearerAuth"); + }); + 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..042cf3c739 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; @@ -79,11 +78,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/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/LowerCaseUnderscoreNamingPolicy.cs b/src/Exceptionless.Web/Utility/LowerCaseUnderscoreNamingPolicy.cs new file mode 100644 index 0000000000..1f4d0d53fe --- /dev/null +++ b/src/Exceptionless.Web/Utility/LowerCaseUnderscoreNamingPolicy.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using Exceptionless.Core.Extensions; + +namespace Exceptionless.Web.Utility; + +/// +/// 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.Web/Utility/OpenApi/DeltaSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs new file mode 100644 index 0000000000..b85f61c014 --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs @@ -0,0 +1,116 @@ +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); + var 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..d12130bd67 --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/DocumentInfoTransformer.cs @@ -0,0 +1,66 @@ +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", + Type = SecuritySchemeType.Http + }, + ["Token"] = new OpenApiSecurityScheme + { + Description = "Authorization token. Example: \"Bearer {apikey}\"", + Name = "access_token", + In = ParameterLocation.Query, + Type = SecuritySchemeType.ApiKey + } + }; + + // Add top-level security requirement (applies to all operations) + document.Security ??= []; + document.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("Basic", document)] = [], + [new OpenApiSecuritySchemeReference("Bearer", document)] = [], + [new OpenApiSecuritySchemeReference("Token", document)] = [] + }); + + 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..447f955b78 --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/ReadOnlyPropertySchemaTransformer.cs @@ -0,0 +1,48 @@ +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 is null || !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) + var schemaPropertyName = property.Name.ToLowerUnderscoredWords(); + + if (schema.Properties.TryGetValue(schemaPropertyName, out var propertySchema)) + { + // Cast to OpenApiSchema to access mutable properties + if (propertySchema is OpenApiSchema mutableSchema) + { + mutableSchema.ReadOnly = true; + } + } + } + + return Task.CompletedTask; + } +} diff --git a/src/Exceptionless.Web/Utility/RemoveProblemJsonFromSuccessResponsesFilter.cs b/src/Exceptionless.Web/Utility/OpenApi/RemoveProblemJsonFromSuccessResponsesTransformer.cs similarity index 57% rename from src/Exceptionless.Web/Utility/RemoveProblemJsonFromSuccessResponsesFilter.cs rename to src/Exceptionless.Web/Utility/OpenApi/RemoveProblemJsonFromSuccessResponsesTransformer.cs index d3b0983f0a..9e3dfcf0c1 100644 --- a/src/Exceptionless.Web/Utility/RemoveProblemJsonFromSuccessResponsesFilter.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/RemoveProblemJsonFromSuccessResponsesTransformer.cs @@ -1,30 +1,36 @@ +using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; -using Swashbuckle.AspNetCore.SwaggerGen; -namespace Exceptionless.Web.Utility; +namespace Exceptionless.Web.Utility.OpenApi; /// -/// Removes application/problem+json content type from successful (2xx) responses. +/// 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 RemoveProblemJsonFromSuccessResponsesFilter : IDocumentFilter +public class RemoveProblemJsonFromSuccessResponsesTransformer : IOpenApiDocumentTransformer { private const string ProblemJsonContentType = "application/problem+json"; - public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) { - if (swaggerDoc.Paths is null) - return; + if (document.Paths is null) + { + return Task.CompletedTask; + } - foreach (var path in swaggerDoc.Paths) + foreach (var path in document.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) { @@ -36,5 +42,7 @@ public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) } } } + + 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..cecb0a9eec --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/RequestBodyContentOperationTransformer.cs @@ -0,0 +1,71 @@ +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) + { + // For controller actions, try to get from ControllerActionDescriptor + if (context.Description.ActionDescriptor is Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor controllerDescriptor) + { + methodInfo = controllerDescriptor.MethodInfo; + } + } + + if (methodInfo is null) + { + return Task.CompletedTask; + } + + var 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..a04d98c8b8 --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/UniqueItemsSchemaTransformer.cs @@ -0,0 +1,42 @@ +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..857100da04 --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/XEnumNamesSchemaTransformer.cs @@ -0,0 +1,40 @@ +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; + } + + var names = Enum.GetNames(type); + var enumNamesArray = new JsonArray(); + + foreach (var 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..645e8f8650 --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/XmlDocumentationOperationTransformer.cs @@ -0,0 +1,165 @@ +using System.Reflection; +using System.Xml.Linq; +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 object _lock = new(); + + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var methodInfo = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .FirstOrDefault(); + + if (methodInfo is null) + { + // For controller actions, try to get from ControllerActionDescriptor + if (context.Description.ActionDescriptor is Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor controllerDescriptor) + { + methodInfo = controllerDescriptor.MethodInfo; + } + } + + if (methodInfo is null) + { + return Task.CompletedTask; + } + + var xmlDoc = GetXmlDocumentation(methodInfo.DeclaringType?.Assembly); + if (xmlDoc is null) + { + return Task.CompletedTask; + } + + var methodMemberName = GetMemberName(methodInfo); + var memberElement = xmlDoc.Descendants("member") + .FirstOrDefault(m => m.Attribute("name")?.Value == methodMemberName); + + if (memberElement is null) + { + return Task.CompletedTask; + } + + var responseElements = memberElement.Elements("response"); + foreach (var responseElement in responseElements) + { + var codeAttribute = responseElement.Attribute("code"); + if (codeAttribute is null) + { + continue; + } + + var statusCode = codeAttribute.Value; + var 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 + }; + } + + return Task.CompletedTask; + } + + private static XDocument? GetXmlDocumentation(Assembly? assembly) + { + if (assembly is null) + { + return null; + } + + var assemblyName = assembly.GetName().Name; + if (assemblyName is null) + { + return null; + } + + lock (_lock) + { + if (_xmlDocCache.TryGetValue(assemblyName, out var cachedDoc)) + { + return cachedDoc; + } + + var 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; + } + + var typeName = declaringType.FullName?.Replace('+', '.'); + var parameters = methodInfo.GetParameters(); + + if (parameters.Length == 0) + { + return $"M:{typeName}.{methodInfo.Name}"; + } + + var parameterTypes = string.Join(",", parameters.Select(p => GetParameterTypeName(p.ParameterType))); + return $"M:{typeName}.{methodInfo.Name}({parameterTypes})"; + } + + private static string GetParameterTypeName(Type type) + { + if (type.IsGenericType) + { + var genericTypeName = type.GetGenericTypeDefinition().FullName; + if (genericTypeName is null) + { + return type.Name; + } + + var backtickIndex = genericTypeName.IndexOf('`'); + if (backtickIndex > 0) + { + genericTypeName = genericTypeName[..backtickIndex]; + } + + var 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/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/AuthControllerTests.cs b/tests/Exceptionless.Tests/Controllers/AuthControllerTests.cs index a00ba54c33..5cb6244e2f 100644 --- a/tests/Exceptionless.Tests/Controllers/AuthControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/AuthControllerTests.cs @@ -47,6 +47,8 @@ protected override async Task ResetDataAsync() [Fact] public async Task CannotSignupWithoutPassword() { + // With System.Text.Json's RespectNullableAnnotations, missing required properties + // fail at deserialization (400 BadRequest) rather than validation (422 UnprocessableEntity) var problemDetails = await SendRequestAsAsync(r => r .Post() .AppendPath("auth/signup") @@ -56,12 +58,13 @@ public async Task CannotSignupWithoutPassword() Email = "test@domain.com", Password = null! }) - .StatusCodeShouldBeUnprocessableEntity() + .StatusCodeShouldBeBadRequest() ); Assert.NotNull(problemDetails); - Assert.Single(problemDetails.Errors); - Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); + Assert.NotEmpty(problemDetails.Errors); + // System.Text.Json deserialization errors use the property name as the key + Assert.Contains(problemDetails.Errors, error => error.Key.Contains("password", StringComparison.OrdinalIgnoreCase)); } [Theory] @@ -449,6 +452,8 @@ public async Task SignupShouldFailWhenUsingExistingAccountWithNoPasswordOrInvali user.MarkEmailAddressVerified(); await _userRepository.AddAsync(user); + // With System.Text.Json's RespectNullableAnnotations, missing required properties + // fail at deserialization (400 BadRequest) rather than validation (422 UnprocessableEntity) var problemDetails = await SendRequestAsAsync(r => r .Post() .AppendPath("auth/signup") @@ -458,12 +463,13 @@ public async Task SignupShouldFailWhenUsingExistingAccountWithNoPasswordOrInvali Email = email, Password = null! }) - .StatusCodeShouldBeUnprocessableEntity() + .StatusCodeShouldBeBadRequest() ); Assert.NotNull(problemDetails); - Assert.Single(problemDetails.Errors); - Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); + Assert.NotEmpty(problemDetails.Errors); + // System.Text.Json deserialization errors use the property name as the key + Assert.Contains(problemDetails.Errors, error => error.Key.Contains("password", StringComparison.OrdinalIgnoreCase)); await SendRequestAsync(r => r .Post() 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 360233d59d..a35616c4bf 100644 --- a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs @@ -1084,10 +1084,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() ); } 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/Serializer/LowerCaseUnderscoreNamingPolicyTests.cs b/tests/Exceptionless.Tests/Serializer/LowerCaseUnderscoreNamingPolicyTests.cs new file mode 100644 index 0000000000..82b688a426 --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/LowerCaseUnderscoreNamingPolicyTests.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Xunit; +using Xunit; +using Xunit.Abstractions; + +namespace Exceptionless.Tests.Serializer; + +/// +/// Tests for LowerCaseUnderscoreNamingPolicy and System.Text.Json serialization for the API layer. +/// +public class LowerCaseUnderscoreNamingPolicyTests : TestWithLoggingBase +{ + public LowerCaseUnderscoreNamingPolicyTests(ITestOutputHelper output) : base(output) { } + + private static readonly JsonSerializerOptions ApiOptions = new() + { + PropertyNamingPolicy = LowerCaseUnderscoreNamingPolicy.Instance, + Converters = { new DeltaJsonConverterFactory() } + }; + + [Fact] + public void NamingPolicy_Instance_ReturnsSingleton() + { + var instance1 = LowerCaseUnderscoreNamingPolicy.Instance; + var instance2 = LowerCaseUnderscoreNamingPolicy.Instance; + + Assert.Same(instance1, instance2); + } + + [Fact] + public void NamingPolicy_AppOptionsProperties_SerializesCorrectly() + { + var model = new AppOptionsModel + { + BaseURL = "https://example.com", + EnableSSL = true, + MaximumRetentionDays = 180, + WebsiteMode = "production" + }; + + string json = JsonSerializer.Serialize(model, ApiOptions); + + Assert.Contains("\"base_u_r_l\":\"https://example.com\"", json); + Assert.Contains("\"enable_s_s_l\":true", json); + Assert.Contains("\"maximum_retention_days\":180", json); + Assert.Contains("\"website_mode\":\"production\"", json); + } + + [Fact] + public void NamingPolicy_EnvironmentProperties_SerializesCorrectly() + { + // 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" + }; + + string json = JsonSerializer.Serialize(model, ApiOptions); + + Assert.Contains("\"o_s_name\":\"Windows 11\"", json); + Assert.Contains("\"o_s_version\":\"10.0.22621\"", json); + Assert.Contains("\"i_p_address\":\"192.168.1.100\"", json); + Assert.Contains("\"machine_name\":\"TEST-MACHINE\"", json); + } + + [Fact] + public void ExternalAuthInfo_Serialize_UsesCamelCasePropertyNames() + { + var authInfo = new ExternalAuthInfo + { + ClientId = "test-client", + Code = "auth-code", + RedirectUri = "https://example.com/callback", + InviteToken = "token123" + }; + + string json = JsonSerializer.Serialize(authInfo, ApiOptions); + + // ExternalAuthInfo uses explicit JsonPropertyName attributes (camelCase) + Assert.Contains("\"clientId\":\"test-client\"", json); + Assert.Contains("\"code\":\"auth-code\"", json); + Assert.Contains("\"redirectUri\":\"https://example.com/callback\"", json); + Assert.Contains("\"inviteToken\":\"token123\"", json); + } + + [Fact] + public void ExternalAuthInfo_Deserialize_ParsesCamelCaseJson() + { + string json = """{"clientId":"my-client","code":"my-code","redirectUri":"https://test.com"}"""; + + var authInfo = JsonSerializer.Deserialize(json, ApiOptions); + + 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() + { + string json = """{"data":"TestValue","is_active":true}"""; + + var delta = JsonSerializer.Deserialize>(json, ApiOptions); + + Assert.NotNull(delta); + Assert.True(delta.TryGetPropertyValue("Data", out object? dataValue)); + Assert.Equal("TestValue", dataValue); + Assert.True(delta.TryGetPropertyValue("IsActive", out object? isActiveValue)); + Assert.Equal(true, isActiveValue); + } + + [Fact] + public void Delta_Deserialize_PartialUpdate_OnlyTracksProvidedProperties() + { + string json = """{"is_active":false}"""; + + var delta = JsonSerializer.Deserialize>(json, ApiOptions); + + Assert.NotNull(delta); + var changedProperties = delta.GetChangedPropertyNames(); + Assert.Single(changedProperties); + Assert.Contains("IsActive", changedProperties); + } + + [Fact] + public void StackStatus_Serialize_UsesStringValue() + { + var stack = new StackStatusModel { Status = StackStatus.Fixed }; + + string json = JsonSerializer.Serialize(stack, ApiOptions); + + Assert.Contains("\"status\":\"fixed\"", json); + } + + [Fact] + public void StackStatus_Deserialize_ParsesStringValue() + { + string json = """{"status":"regressed"}"""; + + var model = JsonSerializer.Deserialize(json, ApiOptions); + + 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/http/admin.http b/tests/http/admin.http index a1f9aa331c..99e14ef801 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}}/openapi/v1.json + ### @userId = {{currentUser.response.body.$.id}}