diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..30cb18d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: ci + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + strategy: + fail-fast: false + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup .NET Core SDK ${{ matrix.dotnet-version }} + uses: actions/setup-dotnet@v3 + with: + dotnet-version: ${{ matrix.dotnet-version }} + + - name: Install dependencies + run: dotnet restore src/Scaffolding.sln + + - name: Build + run: dotnet build --configuration Release --no-restore src/Scaffolding.sln \ No newline at end of file diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml new file mode 100644 index 0000000..614027d --- /dev/null +++ b/.github/workflows/pre-release.yml @@ -0,0 +1,27 @@ +name: pre-release + +on: + release: + types: [prereleased] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v3 + + - name: Set current date as env variable + run: echo "NOW=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV + - name: Echo current date + run: echo $NOW + + - run: dotnet build -c Release src/Scaffolding.sln + + - name: Create the Scaffolding package + run: dotnet pack -c Release --no-build -p:Version="${{github.ref_name}}-alpha.${{ env.NOW }}" -o src/Scaffolding/bin/Release src/Scaffolding/Scaffolding.csproj + + - name: Publish the Scaffolding package + run: dotnet nuget push src/Scaffolding/bin/Release/*.nupkg --api-key ${{secrets.NUGET_API_KEY}} --source https://api.nuget.org/v3/index.json \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..be18e03 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,22 @@ +name: release + +on: + release: + types: [released] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v3 + + - run: dotnet build -c Release src/Scaffolding.sln + + - name: Create the Scaffolding package + run: dotnet pack -c Release --no-build -p:Version=${{github.ref_name}} -o src/Scaffolding/bin/Release src/Scaffolding/Scaffolding.csproj + + - name: Publish the Scaffolding package + run: dotnet nuget push src/Scaffolding/bin/Release/*.nupkg --api-key ${{secrets.NUGET_API_KEY}} --source https://api.nuget.org/v3/index.json \ No newline at end of file diff --git a/src/Scaffolding.sln b/src/Scaffolding.sln new file mode 100644 index 0000000..3c11aa0 --- /dev/null +++ b/src/Scaffolding.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33122.133 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scaffolding", "Scaffolding\Scaffolding.csproj", "{4BD115C2-4838-41FA-840A-7F57CCCC5B7A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4BD115C2-4838-41FA-840A-7F57CCCC5B7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BD115C2-4838-41FA-840A-7F57CCCC5B7A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4BD115C2-4838-41FA-840A-7F57CCCC5B7A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BD115C2-4838-41FA-840A-7F57CCCC5B7A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {292857D8-236D-439B-8525-35046611AFE8} + EndGlobalSection +EndGlobal diff --git a/src/Scaffolding/Api.cs b/src/Scaffolding/Api.cs new file mode 100644 index 0000000..e2ba460 --- /dev/null +++ b/src/Scaffolding/Api.cs @@ -0,0 +1,108 @@ +using Scaffolding.Extensions.Cors; +using Scaffolding.Extensions.CultureInfo; +using Scaffolding.Extensions.Docs; +using Scaffolding.Extensions.ExceptionHandler; +using Scaffolding.Extensions.GracefulShutdown; +using Scaffolding.Extensions.Healthcheck; +using Scaffolding.Extensions.Json; +using Scaffolding.Extensions.Logging; +using Scaffolding.Extensions.RateLimiting; +using Scaffolding.Extensions.RequestKey; +using Scaffolding.Extensions.TimeElapsed; +using Scaffolding.Models; +using Scaffolding.Utilities; +using Serilog; +using Serilog.Context; +using System.Collections.Specialized; +using System.Reflection; + +namespace Scaffolding; + +public static class Api +{ + public static WebApplicationBuilder Initialize(string[] args = null, string customUrls = null, params Assembly[] executingAssemblies) + { + var builder = WebApplication.CreateBuilder(args); + + var apiSettings = builder.Configuration.GetSection("ApiSettings").Get(); + if (apiSettings == null) + { + throw new Exception("'ApiSettings' section in the appsettings.json is required."); + } + + builder.Configuration + .AddJsonFile($"appsettings.{EnvironmentUtility.GetCurrentEnvironment()}.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables(apiSettings.EnvironmentVariablesPrefix); + + builder.WebHost.UseUrls(customUrls ?? $"http://*:{apiSettings.Port}"); + builder.Services.AddSingleton(apiSettings); + // It is needed because we cannot inject a service before + // the configuration is built. This context is used by + // the newtonsoft json settings configuration. + var httpContextAccessor = new HttpContextAccessor(); + builder.Services.AddSingleton(httpContextAccessor); + builder.Services.AddOptions(); + + builder.SetupRateLimiting(); + builder.SetupSwaggerDocs(); + builder.SetupAllowCors(); + builder.SetupGracefulShutdown(); + builder.SetupRequestKey(apiSettings.RequestKeyProperty); + builder.SetupTimeElapsed(apiSettings.TimeElapsedProperty); + builder.ConfigureJsonSettings(); + + var mvc = builder.Services.AddControllers(x => + { + x.EnableEndpointRouting = false; + }); + + foreach (var assembly in executingAssemblies) + { + mvc.AddApplicationPart(assembly); + } + + mvc.SetupScaffoldingJsonSettings(apiSettings, httpContextAccessor); + + return builder; + } + + /// + /// Use scaffolding logging + /// + /// Custom logger to combine with the existing one from scaffolding. + public static void UseLogging(this WebApplicationBuilder builder, Serilog.ILogger customLogger = null) + { + builder.SetupScaffoldingSerilog(customLogger); + } + + public static void UseDefaultConfiguration(this WebApplication app) + { + var apiSettings = app.Services.GetService(); + + app.UseScaffoldingDocumentation(); + app.UseGracefulShutdown(); + app.UseScaffoldingRateLimiting(); + app.UseScaffoldingHealthchecks(); + app.UseScaffoldingSerilog(); + app.UseTimeElapsed(); + app.UseScaffoldingRequestLocalization(); + app.UseScaffoldingExceptionHandler(); + app.UsePathBase(new(apiSettings.GetFullPath())); + app.UseRouting(); + app.UseCors(); + app.UseMvc(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + + public static async Task RunAsync(WebApplication app) + { + var apiSettings = app.Services.GetService(); + LogContext.PushProperty("Application", apiSettings.Name); + LogContext.PushProperty("Version", apiSettings.BuildVersion); + Log.Logger.Information($"[{apiSettings.Name}] is running with version {apiSettings.BuildVersion}"); + await app.RunAsync(); + } +} diff --git a/src/Scaffolding/Controllers/AppInformationController.cs b/src/Scaffolding/Controllers/AppInformationController.cs new file mode 100644 index 0000000..3b78acf --- /dev/null +++ b/src/Scaffolding/Controllers/AppInformationController.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Mvc; +using Scaffolding.Extensions.GracefulShutdown; +using Scaffolding.Extensions.Json; +using Scaffolding.Extensions.Logging; +using Scaffolding.Models; +using Scaffolding.Utilities; +using Scaffolding.Utilities.Converters; + +namespace Scaffolding.Controllers; + +[ApiController] +[Route("/")] +public class AppInformationController : ControllerBase +{ + private readonly ApiSettings _apiSettings; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly GracefulShutdownState GracefulShutdownState; + + public AppInformationController( + ApiSettings apiSettings, + ShutdownSettings shutdownSettings, + GracefulShutdownState gracefulShutdownState, + IHttpContextAccessor httpContextAccessor) + { + this.HandleGracefulShutdown(shutdownSettings); + _apiSettings = apiSettings; + _httpContextAccessor = httpContextAccessor; + this.GracefulShutdownState = gracefulShutdownState; + } + + [HttpGet] + [Produces(typeof(HomeDetails))] + public IActionResult GetAppInfo() + { + this.DisableLogging(); + + return Ok(new HomeDetails + { + RequestsInProgress = GracefulShutdownState.RequestsInProgress, + BuildVersion = _apiSettings?.BuildVersion, + Environment = EnvironmentUtility.GetCurrentEnvironment(), + Application = _apiSettings.Name, + Domain = _apiSettings.Domain, + JsonSerializer = _apiSettings.JsonSerializer, + EnvironmentPrefix = _apiSettings.EnvironmentVariablesPrefix, + TimezoneInfo = new TimezoneInfo(_apiSettings, _httpContextAccessor), + CurrentTime = DateTime.UtcNow, + }); + } + + public class HomeDetails + { + public string Application { get; set; } + public string Domain { get; set; } + public string BuildVersion { get; set; } + public string Environment { get; set; } + public string EnvironmentPrefix { get; set; } + public long RequestsInProgress { get; set; } + public JsonSerializerEnum JsonSerializer { get; set; } + public TimezoneInfo TimezoneInfo { get; set; } + public DateTime CurrentTime { get; set; } + } + + public class TimezoneInfo + { + private readonly ApiSettings _apiSettings; + + public TimezoneInfo(ApiSettings apiSettings, IHttpContextAccessor httpContextAccessor) + { + CurrentTimezone = DateTimeConverter.GetTimeZoneByAspNetHeader( + httpContextAccessor, + apiSettings.TimezoneHeader).Id; + + _apiSettings = apiSettings; + } + + public static string UtcNow => DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss"); + + public static DateTime CurrentNow => DateTime.UtcNow; + + public string DefaultTimezone => _apiSettings.TimezoneDefaultInfo.Id; + + public static string CurrentTimezone { get; set; } + + public string TimezoneHeader => _apiSettings.TimezoneHeader; + } + +} \ No newline at end of file diff --git a/src/Scaffolding/Controllers/BaseController.cs b/src/Scaffolding/Controllers/BaseController.cs new file mode 100644 index 0000000..701e45f --- /dev/null +++ b/src/Scaffolding/Controllers/BaseController.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using Scaffolding.Extensions.Cors; +using WebApi.Models.Response; + +namespace Scaffolding.Controllers; + +[EnableCors(CorsServiceExtension.CorsName)] +public class BaseController : ControllerBase +{ + public BaseController() + { + } + + protected IActionResult CreateJsonResponse(ApiResponse response) + { + IActionResult result; + + if (response.Content != null) + { + result = new JsonResult(response.Content) + { + StatusCode = (int)response.StatusCode + }; + } + else + { + result = new StatusCodeResult((int)response.StatusCode); + Response.ContentType = "application/json"; + } + + if (response.Headers != null) + { + foreach (var header in response.Headers) + { + Response.Headers[header.Key] = header.Value; + } + } + + return result; + } +} diff --git a/src/Scaffolding/Extensions/Cors/CorsMiddlewareExtension.cs b/src/Scaffolding/Extensions/Cors/CorsMiddlewareExtension.cs new file mode 100644 index 0000000..e8ba0f2 --- /dev/null +++ b/src/Scaffolding/Extensions/Cors/CorsMiddlewareExtension.cs @@ -0,0 +1,9 @@ +namespace Scaffolding.Extensions.Cors; + +public static class CorsMiddlewareExtension +{ + public static void AllowCors(this IApplicationBuilder app) + { + app.UseCors(CorsServiceExtension.CorsName); + } +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/Cors/CorsServiceExtension.cs b/src/Scaffolding/Extensions/Cors/CorsServiceExtension.cs new file mode 100644 index 0000000..28e436d --- /dev/null +++ b/src/Scaffolding/Extensions/Cors/CorsServiceExtension.cs @@ -0,0 +1,14 @@ +namespace Scaffolding.Extensions.Cors; + +public static class CorsServiceExtension +{ + public const string CorsName = "EnableAll"; + + public static void SetupAllowCors(this WebApplicationBuilder builder) + { + builder.Services.AddCors(o => o.AddPolicy(CorsName, builder => + { + builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); + })); + } +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/CultureInfo/CultureInfoMiddlewareExtension.cs b/src/Scaffolding/Extensions/CultureInfo/CultureInfoMiddlewareExtension.cs new file mode 100644 index 0000000..31d1c8d --- /dev/null +++ b/src/Scaffolding/Extensions/CultureInfo/CultureInfoMiddlewareExtension.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Localization; +using Scaffolding.Models; + +namespace Scaffolding.Extensions.CultureInfo; + +public static class CultureInfoMiddlewareExtension +{ + public static void UseScaffoldingRequestLocalization(this WebApplication app) + { + var apiSettings = app.Services.GetService(); + + if (apiSettings.SupportedCultures?.Any() == true) + { + app.UseRequestLocalization(options => + { + options.AddSupportedCultures(apiSettings.SupportedCultures); + options.AddSupportedUICultures(apiSettings.SupportedCultures); + options.SetDefaultCulture(apiSettings.SupportedCultures.FirstOrDefault()); + options.RequestCultureProviders = new List + { + new AcceptLanguageHeaderRequestCultureProvider { Options = options } + }; + }); + } + } +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/Docs/DocsMiddleware.cs b/src/Scaffolding/Extensions/Docs/DocsMiddleware.cs new file mode 100644 index 0000000..753960c --- /dev/null +++ b/src/Scaffolding/Extensions/Docs/DocsMiddleware.cs @@ -0,0 +1,28 @@ +namespace Scaffolding.Extensions.Docs; + +internal static class DocsMiddlewareExtension +{ + public static void UseScaffoldingDocumentation(this IApplicationBuilder app) + { + var docsSettings = app.ApplicationServices.GetService(); + + if (docsSettings?.Enabled == true) + { + var title = docsSettings?.Title ?? "API Reference"; + + app.UseStaticFiles(); + app.UseDirectoryBrowser(); + app.UseSwagger(c => + { + c.RouteTemplate = docsSettings.SwaggerJsonTemplateUrl.TrimStart('/'); + }); + + app.UseReDoc(c => + { + c.RoutePrefix = docsSettings.RedocUrl.TrimStart('/'); + c.SpecUrl = docsSettings.SwaggerJsonUrl; + c.DocumentTitle = title; + }); + } + } +} diff --git a/src/Scaffolding/Extensions/Docs/DocsServiceExtension.cs b/src/Scaffolding/Extensions/Docs/DocsServiceExtension.cs new file mode 100644 index 0000000..034ba4b --- /dev/null +++ b/src/Scaffolding/Extensions/Docs/DocsServiceExtension.cs @@ -0,0 +1,89 @@ +using Microsoft.OpenApi.Models; +using Scaffolding.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Reflection; +using static Scaffolding.Utilities.SwaggerUtilities; + +namespace Scaffolding.Extensions.Docs; + +public static class DocsServiceExtension +{ + public static DocsSettings DocsSettings { get; private set; } + private const string DEFAULT_VERSION_IF_NOT_FILLED = "v1"; + + public static void SetupSwaggerDocs(this WebApplicationBuilder builder) + { + var apiSettings = builder.Configuration.GetSection("ApiSettings").Get(); + var docsSettings = builder.Configuration.GetSection("DocsSettings").Get(); + + if (docsSettings?.Enabled == true) + { + try + { + GenerateSwaggerUrl(docsSettings, apiSettings); + + builder.Services.AddSwaggerGen(options => + { + var readme = string.Empty; + try + { + if (string.IsNullOrWhiteSpace(docsSettings.PathToReadme) == false) + { + readme = File.ReadAllText(docsSettings.PathToReadme); + } + } + catch (Exception) + { + Console.WriteLine($"[ERROR] Swagger markdown ({docsSettings.PathToReadme}) could not be loaded."); + } + + options.SchemaFilter(); + SwaggerEnum.Enums = docsSettings.IgnoredEnums; + + options.CustomSchemaIds(x => x.FullName); + options.CustomOperationIds(apiDesc => + { + return apiDesc.TryGetMethodInfo(out MethodInfo methodInfo) + ? methodInfo.Name : null; + }); + + options.IgnoreObsoleteActions(); + options.IgnoreObsoleteProperties(); + + options.SchemaFilter(); + options.OperationFilter(); + var version = string.IsNullOrEmpty(apiSettings.Version) + ? DEFAULT_VERSION_IF_NOT_FILLED + : apiSettings.Version; + options.SwaggerDoc(version, new OpenApiInfo + { + Title = docsSettings.Title, + Version = version, + Description = readme, + Contact = new OpenApiContact + { + Name = docsSettings.AuthorName, + Email = docsSettings.AuthorEmail + } + }); + }); + DocsSettings = docsSettings; + builder.Services.AddSingleton(docsSettings); + builder.Services.AddSwaggerGenNewtonsoftSupport(); + } + catch (Exception e) + { + Console.WriteLine($"[ERROR] Swagger exception: {e.Message}"); + } + } + } + + private static void GenerateSwaggerUrl(DocsSettings docsSettings, ApiSettings apiSettings) + { + var version = string.IsNullOrEmpty(apiSettings.Version) ? DEFAULT_VERSION_IF_NOT_FILLED : apiSettings.Version; + + docsSettings.SwaggerJsonTemplateUrl = "/swagger/{documentName}/swagger.json"; + docsSettings.SwaggerJsonUrl = $"/swagger/{version}/swagger.json"; + docsSettings.RedocUrl = apiSettings.GetFullPath().Trim('/') + "/docs"; + } +} diff --git a/src/Scaffolding/Extensions/Docs/DocsSettings.cs b/src/Scaffolding/Extensions/Docs/DocsSettings.cs new file mode 100644 index 0000000..206a655 --- /dev/null +++ b/src/Scaffolding/Extensions/Docs/DocsSettings.cs @@ -0,0 +1,23 @@ +namespace Scaffolding.Extensions.Docs; + +public class DocsSettings +{ + public bool Enabled { get; set; } + public string Title { get; set; } + public string AuthorName { get; set; } + public string AuthorEmail { get; set; } + public string PathToReadme { get; set; } + public string RedocUrl { get; set; } + public string SwaggerJsonUrl { get; set; } + public string SwaggerJsonTemplateUrl { get; set; } + public List IgnoredEnums { get; set; } + + public IEnumerable GetDocsFinalRoutes() + { + return new List + { + SwaggerJsonUrl, + RedocUrl + }; + } +} diff --git a/src/Scaffolding/Extensions/Docs/QueryAndPathCaseOperationFilter.cs b/src/Scaffolding/Extensions/Docs/QueryAndPathCaseOperationFilter.cs new file mode 100644 index 0000000..a6d4321 --- /dev/null +++ b/src/Scaffolding/Extensions/Docs/QueryAndPathCaseOperationFilter.cs @@ -0,0 +1,55 @@ +using Microsoft.OpenApi.Models; +using Scaffolding.Utilities; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Text.RegularExpressions; + +namespace Scaffolding.Extensions.Docs; + +internal class QueryAndPathCaseOperationFilter : IOperationFilter +{ + public QueryAndPathCaseOperationFilter() + { + } + + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (operation.Parameters != null) + { + foreach (var param in operation.Parameters.Where(p => p.In == ParameterLocation.Query || p.In == ParameterLocation.Path)) + { + param.Name = param.Name.GetValueConsideringCurrentCase(); + } + + var grouped = operation.Parameters + .Where(p => p.In == ParameterLocation.Query || p.In == ParameterLocation.Path) + .GroupBy(r => r.Name); + + var queryAndPath = grouped.Select(r => r.OrderBy(p => p.In).First()).ToList(); + + operation.Parameters.ToList() + .RemoveAll(p => p.In == ParameterLocation.Query || p.In == ParameterLocation.Path); + + operation.Parameters.ToList().AddRange(queryAndPath); + } + + if (context.ApiDescription.ParameterDescriptions != null) + { + foreach (var param in context.ApiDescription.ParameterDescriptions) + { + param.Name = param.Name.GetValueConsideringCurrentCase(); + } + } + + var path = context.ApiDescription.RelativePath; + var matches = Regex.Matches(path, @"[{]{1}[\w\\_]*[}]{1}"); + + foreach (var match in matches) + { + var oldValue = match.ToString(); + var newValue = "{" + oldValue.TrimStart('{').TrimEnd('}').GetValueConsideringCurrentCase() + "}"; + path = path.Replace(oldValue, newValue); + } + + context.ApiDescription.RelativePath = path; + } +} diff --git a/src/Scaffolding/Extensions/ExceptionHandler/ExceptionHandlerMiddleware.cs b/src/Scaffolding/Extensions/ExceptionHandler/ExceptionHandlerMiddleware.cs new file mode 100644 index 0000000..ffa3123 --- /dev/null +++ b/src/Scaffolding/Extensions/ExceptionHandler/ExceptionHandlerMiddleware.cs @@ -0,0 +1,110 @@ +using Newtonsoft.Json; +using Scaffolding.Extensions.Json; +using Scaffolding.Models; +using Scaffolding.Utilities; +using System.Net; +using WebApi.Models.Exceptions; +using WebApi.Models.Helpers; + +namespace Scaffolding.Extensions.ExceptionHandler; + +public class ExceptionHandlerMiddleware +{ + private readonly RequestDelegate _next; + private readonly bool _isDevelopment; + + public static Func ChangeErrorFormat; + + public ExceptionHandlerMiddleware(RequestDelegate next) + { + _next = next; + _isDevelopment = EnvironmentUtility.IsDevelopment(); + } + + public async Task Invoke(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + await HandleExceptionAsync(context, ex, _isDevelopment); + } + } + + private static Task HandleExceptionAsync(HttpContext context, Exception exception, bool isDevelopment) + { + try + { + context.Request.Body.Position = 0; + } + catch { } + + if (exception is ApiException apiException) + { + return ApiException(context, apiException); + } + else + { + return GenericError(context, exception, isDevelopment); + } + } + + private static async Task GenericError(HttpContext context, Exception exception, bool isDevelopment) + { + context.Items.Add("Exception", exception); + + if (isDevelopment) + { + var exceptionContainer = new ExceptionContainer(exception); + var serializedObject = JsonConvert.SerializeObject(exceptionContainer, JsonSerializerServiceExtension.JsonSerializerSettings); + await context.Response.WriteAsync(serializedObject); + context.Response.Body.Position = 0; + } + + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + } + + private static async Task ApiException(HttpContext context, ApiException exception) + { + var apiResponse = exception.ToApiResponse(); + + var statusCode = (int)apiResponse.StatusCode; + + if (exception is PermanentRedirectException) + { + statusCode = 308; + var location = $"{context.Request.Path}{context.Request.QueryString}"; + + context.Response.Headers["Location"] = location; + } + + context.Response.ContentType = "application/json"; + context.Response.StatusCode = statusCode; + + if (apiResponse.Content != null && ChangeErrorFormat == null) + { + var serializedObject = JsonConvert.SerializeObject(apiResponse.Content, JsonSerializerServiceExtension.JsonSerializerSettings); + await context.Response.WriteAsync(serializedObject); + context.Response.Body.Position = 0; + } + else if (ChangeErrorFormat != null) + { + var content = ChangeErrorFormat.Invoke(exception); + var serializedObject = JsonConvert.SerializeObject(content, JsonSerializerServiceExtension.JsonSerializerSettings); + await context.Response.WriteAsync(serializedObject); + context.Response.Body.Position = 0; + } + } + +} + +public static class ExceptionHandlerMiddlewareExtension +{ + public static void UseScaffoldingExceptionHandler(this IApplicationBuilder app) + { + app.UseMiddleware(); + } +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/GracefulShutdown/GracefulShutdownExtensions.cs b/src/Scaffolding/Extensions/GracefulShutdown/GracefulShutdownExtensions.cs new file mode 100644 index 0000000..d62b7bd --- /dev/null +++ b/src/Scaffolding/Extensions/GracefulShutdown/GracefulShutdownExtensions.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Mvc; +using WebApi.Models.Exceptions; + +namespace Scaffolding.Extensions.GracefulShutdown; + +public static class GracefulShutdownExtensions +{ + public static void HandleGracefulShutdown(this ControllerBase _, ShutdownSettings shutdownSettings) + { + if (shutdownSettings?.Enabled == true) + { + if (shutdownSettings.GracefulShutdownState.StopRequested && shutdownSettings.Redirect) + { + throw new PermanentRedirectException(null); + } + else if (shutdownSettings.GracefulShutdownState.StopRequested) + { + throw new ServiceUnavailableException("Service is unavailable for temporary maintenance."); + } + } + } + + public static void SetupGracefulShutdown(this WebApplicationBuilder builder) + { + var shutdownSettings = builder.Configuration.GetSection("ShutdownSettings").Get(); + + builder.Services.AddSingleton(shutdownSettings); + builder.Services.AddSingleton(shutdownSettings.GracefulShutdownState); + builder.Services.AddSingleton(shutdownSettings.GracefulShutdownState); + } + + public static void UseGracefulShutdown(this WebApplication app) + { + var shutdownSettings = app.Services.GetService(); + + if (shutdownSettings?.Enabled == true) + { + app.UseMiddleware(); + } + } +} diff --git a/src/Scaffolding/Extensions/GracefulShutdown/GracefulShutdownMiddleware.cs b/src/Scaffolding/Extensions/GracefulShutdown/GracefulShutdownMiddleware.cs new file mode 100644 index 0000000..13755cb --- /dev/null +++ b/src/Scaffolding/Extensions/GracefulShutdown/GracefulShutdownMiddleware.cs @@ -0,0 +1,101 @@ +using Scaffolding.Extensions.Logging; +using Serilog; +using Serilog.Context; + +namespace Scaffolding.Extensions.GracefulShutdown; + +internal class GracefulShutdownMiddleware +{ + private readonly RequestDelegate _next; + private readonly GracefulShutdownState _state; + private readonly ShutdownSettings _shutdownSettings; + private readonly LogSettings _logSettings; + private readonly IHostApplicationLifetime _applicationLifetime; + + private DateTime _shutdownStarted; + + public GracefulShutdownMiddleware( + RequestDelegate next, + IHostApplicationLifetime applicationLifetime, + GracefulShutdownState state, + ShutdownSettings shutdownSettings, + LogSettings logSettings) + { + _applicationLifetime = applicationLifetime ?? throw new ArgumentNullException(nameof(applicationLifetime)); + _next = next ?? throw new ArgumentNullException(nameof(next)); + _state = state ?? throw new ArgumentNullException(nameof(state)); + + _shutdownSettings = shutdownSettings; + _logSettings = logSettings; + + applicationLifetime.ApplicationStopping.Register(OnApplicationStopping); + applicationLifetime.ApplicationStopped.Register(OnApplicationStopped); + } + + public async Task Invoke(HttpContext context) + { + var ignoredRequest = _state.StopRequested; + + if (!ignoredRequest) + { + _state.NotifyRequestStarted(); + } + + try + { + await _next.Invoke(context); + } + finally + { + if (!ignoredRequest) + { + _state.NotifyRequestFinished(); + } + } + } + + private void OnApplicationStopping() + { + _state.NotifyStopRequested(); + _shutdownStarted = DateTime.UtcNow; + var shutdownLimit = _shutdownStarted.Add(_shutdownSettings.ShutdownTimeoutTimeSpan); + + while (_state.RequestsInProgress > 0 && DateTime.UtcNow < shutdownLimit) + { + LogInfo("Application stopping, requests in progress: {RequestsInProgress}", _state.RequestsInProgress); + Thread.Sleep(1000); + } + } + + private void OnApplicationStopped() + { + if (_state.RequestsInProgress > 0) + { + LogError("Application stopped, some requests were still in progress: {RequestsInProgress}", _state.RequestsInProgress); + } + else + { + LogInfo("Application stopped, requests in progress: {RequestsInProgress}", _state.RequestsInProgress); + } + + _applicationLifetime.StopApplication(); + + Log.CloseAndFlush(); + } + + public void LogInfo(string message, long requestsInProgress) + { + LogContext.PushProperty("RequestsInProgress", requestsInProgress); + LogContext.PushProperty("Environment", Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")); + + Log.Logger.Information(_logSettings.TitlePrefix + " " + message); + } + + public void LogError(string message, long requestsInProgress) + { + LogContext.PushProperty("RequestsInProgress", requestsInProgress); + LogContext.PushProperty("Environment", Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")); + + Log.Logger.Error(_logSettings.TitlePrefix + " " + message); + } +} diff --git a/src/Scaffolding/Extensions/GracefulShutdown/GracefulShutdownState.cs b/src/Scaffolding/Extensions/GracefulShutdown/GracefulShutdownState.cs new file mode 100644 index 0000000..4f90166 --- /dev/null +++ b/src/Scaffolding/Extensions/GracefulShutdown/GracefulShutdownState.cs @@ -0,0 +1,42 @@ +namespace Scaffolding.Extensions.GracefulShutdown; + +public class GracefulShutdownState : IRequestsCountProvider +{ + private long _requestsInProgress; + public long RequestsInProgress => Volatile.Read(ref _requestsInProgress); + + private long _requestsProcessed; + public long RequestsProcessed => Volatile.Read(ref _requestsProcessed); + + private bool _stopRequested; + public bool StopRequested => Volatile.Read(ref _stopRequested); + + public void NotifyRequestStarted() + { + Interlocked.Increment(ref _requestsInProgress); + } + + public void NotifyRequestFinished() + { + Interlocked.Decrement(ref _requestsInProgress); + Interlocked.Increment(ref _requestsProcessed); + } + + public void NotifyStopRequested() + { + Volatile.Write(ref _stopRequested, true); + } +} + +public interface IRequestsCountProvider +{ + long RequestsInProgress { get; } + + long RequestsProcessed { get; } + + void NotifyRequestStarted(); + + void NotifyRequestFinished(); + + void NotifyStopRequested(); +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/GracefulShutdown/ShutdownSettings.cs b/src/Scaffolding/Extensions/GracefulShutdown/ShutdownSettings.cs new file mode 100644 index 0000000..5da58b0 --- /dev/null +++ b/src/Scaffolding/Extensions/GracefulShutdown/ShutdownSettings.cs @@ -0,0 +1,30 @@ +namespace Scaffolding.Extensions.GracefulShutdown; + +public class ShutdownSettings +{ + /// + /// enables gracefull shutdown + /// + public bool Enabled { get; set; } + + /// + /// false - incoming requests will get 500 code after shutdown initiation + /// true - incoming requests will be redirected with 308 code, and same url + /// + public bool Redirect { get; set; } + + /// + /// forces shutdown after X seconds + /// + public int ShutdownTimeoutInSeconds { get; set; } = 60; + + /// + /// forces shutdown after X seconds - timespan format + /// + public TimeSpan ShutdownTimeoutTimeSpan => TimeSpan.FromSeconds(ShutdownTimeoutInSeconds); + + /// + /// State for gracefull shutdown + /// + public GracefulShutdownState GracefulShutdownState { get; set; } = new(); +} diff --git a/src/Scaffolding/Extensions/Healthcheck/HealthcheckMiddleware.cs b/src/Scaffolding/Extensions/Healthcheck/HealthcheckMiddleware.cs new file mode 100644 index 0000000..303f088 --- /dev/null +++ b/src/Scaffolding/Extensions/Healthcheck/HealthcheckMiddleware.cs @@ -0,0 +1,89 @@ +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Scaffolding.Models; +using System.Text; +using System.Text.Json; + +namespace Scaffolding.Extensions.Healthcheck +{ + public static class HealthcheckMiddleware + { + public static void UseScaffoldingHealthchecks(this IApplicationBuilder app) + { + var apiSettings = app.ApplicationServices.GetService(); + var healthcheckSettings = app.ApplicationServices.GetService(); + + if (healthcheckSettings?.Enabled == true) + { + var options = new HealthCheckOptions + { + AllowCachingResponses = true, + ResponseWriter = WriteResponse + }; + + var path = new PathString($"/{healthcheckSettings.Path?.Trim('/')}"); + app.UseHealthChecks(path, options); + } + } + + private static Task WriteResponse(HttpContext context, HealthReport healthReport) + { + context.Response.ContentType = "application/json; charset=utf-8"; + + var options = new JsonWriterOptions { Indented = true }; + + using var memoryStream = new MemoryStream(); + using (var jsonWriter = new Utf8JsonWriter(memoryStream, options)) + { + jsonWriter.WriteStartObject(); + jsonWriter.WriteString("status", healthReport.Status.ToString()); + jsonWriter.WriteStartObject("results"); + + foreach (var healthReportEntry in healthReport.Entries) + { + jsonWriter.WriteStartObject(healthReportEntry.Key); + jsonWriter.WriteString("status", + healthReportEntry.Value.Status.ToString()); + + if (!string.IsNullOrEmpty(healthReportEntry.Value.Description)) + { + jsonWriter.WriteString("description", + healthReportEntry.Value.Description); + } + + if (healthReportEntry.Value.Data != null && healthReportEntry.Value.Data.Count > 0) + { + jsonWriter.WriteStartObject("data"); + + foreach (var item in healthReportEntry.Value.Data) + { + jsonWriter.WritePropertyName(item.Key); + + JsonSerializer.Serialize(jsonWriter, item.Value, + item.Value?.GetType() ?? typeof(object)); + } + + jsonWriter.WriteEndObject(); + } + + + jsonWriter.WriteEndObject(); + } + + jsonWriter.WriteEndObject(); + jsonWriter.WriteEndObject(); + } + + return context.Response.WriteAsync( + Encoding.UTF8.GetString(memoryStream.ToArray())); + } + + public static string GetFullPath(ApiSettings apiSettings, HealthcheckSettings healthcheckSettings) + { + var fullPath = apiSettings.GetFullPath(); + var finalPathPart = healthcheckSettings.Path?.Trim('/'); + + return (fullPath ?? "") + "/" + (finalPathPart ?? "healthcheck"); + } + } +} diff --git a/src/Scaffolding/Extensions/Healthcheck/HealthcheckServiceExtension.cs b/src/Scaffolding/Extensions/Healthcheck/HealthcheckServiceExtension.cs new file mode 100644 index 0000000..8cb2df6 --- /dev/null +++ b/src/Scaffolding/Extensions/Healthcheck/HealthcheckServiceExtension.cs @@ -0,0 +1,17 @@ +namespace Scaffolding.Extensions.Healthcheck; + +public static class HealthcheckServiceExtension +{ + public static IHealthChecksBuilder SetupHealthcheck(this WebApplicationBuilder builder) + { + var healthcheckSettings = builder.Configuration.GetSection("HealthcheckSettings").Get(); + builder.Services.AddSingleton(healthcheckSettings); + + if (healthcheckSettings?.Enabled == true) + { + return builder.Services.AddHealthChecks(); + } + + return null; + } +} diff --git a/src/Scaffolding/Extensions/Healthcheck/HealthcheckSettings.cs b/src/Scaffolding/Extensions/Healthcheck/HealthcheckSettings.cs new file mode 100644 index 0000000..2b6c2a9 --- /dev/null +++ b/src/Scaffolding/Extensions/Healthcheck/HealthcheckSettings.cs @@ -0,0 +1,8 @@ +namespace Scaffolding.Extensions.Healthcheck; + +public class HealthcheckSettings +{ + public bool Enabled { get; set; } + public string Path { get; set; } + public bool LogEnabled { get; set; } +} diff --git a/src/Scaffolding/Extensions/Json/JsonExtension.cs b/src/Scaffolding/Extensions/Json/JsonExtension.cs new file mode 100644 index 0000000..f17263b --- /dev/null +++ b/src/Scaffolding/Extensions/Json/JsonExtension.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json.Linq; + +namespace Scaffolding.Extensions.Json; + +public static class JsonExtension +{ + public static object DeserializeAsObject(this string json) + { + return DeserializeAsObjectCore(JToken.Parse(json)); + } + + public static object DeserializeAsObjectCore(JToken token) + { + switch (token.Type) + { + case JTokenType.Object: + return token.Children() + .ToDictionary(prop => prop.Name, + prop => DeserializeAsObjectCore(prop.Value)); + + case JTokenType.Array: + return token.Select(DeserializeAsObjectCore).ToList(); + + default: + return ((JValue)token).Value; + } + } +} diff --git a/src/Scaffolding/Extensions/Json/JsonMasking.cs b/src/Scaffolding/Extensions/Json/JsonMasking.cs new file mode 100644 index 0000000..b254276 --- /dev/null +++ b/src/Scaffolding/Extensions/Json/JsonMasking.cs @@ -0,0 +1,90 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Text.RegularExpressions; + +namespace Scaffolding.Extensions.Json; + +/// +/// Json Masking Extension +/// +public static class JsonMasking +{ + /// + /// Mask fields + /// + /// json to mask properties + /// insensitive property array + /// mask to replace property value + /// + public static string MaskFields(this string json, string[] blacklist, string mask) + { + if (string.IsNullOrWhiteSpace(json) == true) + { + throw new ArgumentNullException(nameof(json)); + } + + if (blacklist == null) + { + throw new ArgumentNullException(nameof(blacklist)); + } + + if (blacklist.Any() == false) + { + return json; + } + + var jsonObject = (JObject)JsonConvert.DeserializeObject(json); + MaskFieldsFromJToken(jsonObject, blacklist, mask); + + return jsonObject.ToString(); + } + + /// + /// Mask fields from JToken + /// + /// + /// + /// + /// + private static void MaskFieldsFromJToken(JToken token, string[] blacklist, string mask) + { + JContainer container = token as JContainer; + if (container == null) + { + return; // abort recursive + } + + List removeList = new List(); + foreach (JToken jtoken in container.Children()) + { + if (jtoken is JProperty prop) + { + var matching = blacklist.Any(item => + { + return + Regex.IsMatch(prop.Path, WildCardToRegular(item), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + }); + + if (matching) + { + removeList.Add(jtoken); + } + } + + // call recursive + MaskFieldsFromJToken(jtoken, blacklist, mask); + } + + // replace + foreach (JToken el in removeList) + { + var prop = (JProperty)el; + prop.Value = mask; + } + } + + private static string WildCardToRegular(string value) + { + return "^" + Regex.Escape(value).Replace("\\*", ".*") + "$"; + } +} diff --git a/src/Scaffolding/Extensions/Json/JsonSerializerEnum.cs b/src/Scaffolding/Extensions/Json/JsonSerializerEnum.cs new file mode 100644 index 0000000..3327f5c --- /dev/null +++ b/src/Scaffolding/Extensions/Json/JsonSerializerEnum.cs @@ -0,0 +1,8 @@ +namespace Scaffolding.Extensions.Json; + +public enum JsonSerializerEnum +{ + Camelcase, + Snakecase, + Lowercase, +} diff --git a/src/Scaffolding/Extensions/Json/JsonSerializerServiceExtension.cs b/src/Scaffolding/Extensions/Json/JsonSerializerServiceExtension.cs new file mode 100644 index 0000000..ac874d6 --- /dev/null +++ b/src/Scaffolding/Extensions/Json/JsonSerializerServiceExtension.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; +using Scaffolding.Extensions.QueryFormatter; +using Scaffolding.Extensions.RoutePrefix; +using Scaffolding.Models; +using Scaffolding.Utilities; +using Scaffolding.Utilities.Converters; + +namespace Scaffolding.Extensions.Json; + +public static class JsonSerializerServiceExtension +{ + public static JsonSerializerSettings JsonSerializerSettings { get; set; } + + public static JsonSerializer JsonSerializer { get; set; } + + public static void ConfigureJsonSettings(this WebApplicationBuilder builder) + { + var apiSettings = builder.Configuration.GetSection("ApiSettings").Get(); + + var jsonSerializerMode = apiSettings.JsonSerializer; + CaseUtility.JsonSerializerMode = jsonSerializerMode; + + JsonSerializerSettings = null; + JsonSerializer = null; + + switch (jsonSerializerMode) + { + case JsonSerializerEnum.Camelcase: + JsonSerializer = JsonUtility.CamelCaseJsonSerializer; + JsonSerializerSettings = JsonUtility.CamelCaseJsonSerializerSettings; + break; + case JsonSerializerEnum.Lowercase: + JsonSerializer = JsonUtility.LowerCaseJsonSerializer; + JsonSerializerSettings = JsonUtility.LowerCaseJsonSerializerSettings; + break; + case JsonSerializerEnum.Snakecase: + JsonSerializer = JsonUtility.SnakeCaseJsonSerializer; + JsonSerializerSettings = JsonUtility.SnakeCaseJsonSerializerSettings; + break; + default: + break; + } + + JsonConvert.DefaultSettings = () => JsonSerializerSettings; + + builder.Services.AddSingleton(x => JsonSerializer); + builder.Services.AddSingleton(x => JsonSerializerSettings); + + DateTimeConverter.DefaultTimeZone = apiSettings.TimezoneDefaultInfo; + } + + public static void SetupScaffoldingJsonSettings(this IMvcBuilder mvc, ApiSettings apiSettings, IHttpContextAccessor httpContextAccessor) + { + mvc.AddMvcOptions(options => + { + // Configure query string formatter by json serializer mode + options.AddQueryFormatter(CaseUtility.JsonSerializerMode); + // Configure json property formatter by json serializer mode + options.AddPathFormatter(CaseUtility.JsonSerializerMode); + }) + .AddNewtonsoftJson(options => + { + options.SerializerSettings.ContractResolver = JsonSerializerSettings.ContractResolver; + options.SerializerSettings.NullValueHandling = JsonSerializerSettings.NullValueHandling; + options.SerializerSettings.Converters.Add(new DateTimeConverter(() => + { + return DateTimeConverter.GetTimeZoneByAspNetHeader(httpContextAccessor, apiSettings.TimezoneHeader); + })); + + foreach (var converter in JsonSerializerSettings.Converters) + { + options.SerializerSettings.Converters.Add(converter); + } + }); + } +} diff --git a/src/Scaffolding/Extensions/Logging/CommunicationLogger.cs b/src/Scaffolding/Extensions/Logging/CommunicationLogger.cs new file mode 100644 index 0000000..9910eeb --- /dev/null +++ b/src/Scaffolding/Extensions/Logging/CommunicationLogger.cs @@ -0,0 +1,192 @@ +using Serilog.Context; +using Serilog; +using System.Net; +using Scaffolding.Utilities.Extractors; +using Microsoft.AspNetCore.Mvc.Controllers; + +namespace Scaffolding.Extensions.Logging; + +public class CommunicationLogger : ICommunicationLogger +{ + /// + /// Default Log Information Title + /// + public const string DefaultInformationTitle = "HTTP {Method} {Path} from {Ip} responded {StatusCode} in {ElapsedMilliseconds} ms"; + + /// + /// Default Log Error Title + /// + public const string DefaultErrorTitle = "HTTP {Method} {Path} from {Ip} responded {StatusCode} in {ElapsedMilliseconds} ms"; + + /// + /// Serilog Configuration + /// + public LogConfiguration LogConfiguration { get; set; } + + /// + /// Constructor with configuration + /// + /// + public CommunicationLogger(LogConfiguration configuration) + { + this.SetupCommunicationLogger(configuration); + } + + /// + /// Constructor using global logger definition + /// + public CommunicationLogger() + { + this.SetupCommunicationLogger(null); + } + + /// + /// Log context + /// + /// + public async Task LogData(HttpContext context) + { + var routeDisabled = (this.LogConfiguration.IgnoredRoutes?.ToList() + .Where(r => context.Request.Path.ToString().StartsWith(r)).Any() == true); + + if ((context?.Items == null || context.Items.TryGetValue(DisableLoggingExtension.ITEM_NAME, out object disableSerilog) == false) + && routeDisabled == false) + { + await this.LogData(context, null); + } + } + + /// + /// Log context and exception + /// + /// + /// + public async Task LogData(HttpContext context, Exception exception) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var statusCode = context.GetStatusCode(exception); + + string exceptionMessage = null; + string exceptionStackTrace = null; + + if (exception != null) + { + exceptionMessage = HandleFieldSize(exception.Message, ExceptionMaxLengthExtension.ErrorMessageLength); + exceptionStackTrace = HandleFieldSize(exception.StackTrace, ExceptionMaxLengthExtension.ErrorExceptionLength); + } + + var endpoint = context.GetEndpoint(); + if(endpoint != null) + { + var controllerActionDescriptor = endpoint + .Metadata + .GetMetadata(); + + if(controllerActionDescriptor != null) + { + LogContext.PushProperty("Controller", controllerActionDescriptor.ControllerName); + LogContext.PushProperty("Action", controllerActionDescriptor.ActionName); + } + } + + LogContext.PushProperty("RequestBody", await context.GetRequestBody(this.LogConfiguration.BlacklistRequest)); + LogContext.PushProperty("Method", context.Request.Method); + LogContext.PushProperty("Path", context.GetPath(this.LogConfiguration.HttpContextBlacklist)); + LogContext.PushProperty("Host", context.GetHost()); + LogContext.PushProperty("Port", context.GetPort()); + LogContext.PushProperty("Url", context.GetFullUrl(this.LogConfiguration.QueryStringBlacklist, this.LogConfiguration.HttpContextBlacklist)); + LogContext.PushProperty("QueryString", context.GetRawQueryString(this.LogConfiguration.QueryStringBlacklist)); + LogContext.PushProperty("Query", context.GetQueryString(this.LogConfiguration.QueryStringBlacklist)); + LogContext.PushProperty("RequestHeaders", context.GetRequestHeaders(this.LogConfiguration.HeaderBlacklist)); + LogContext.PushProperty("Ip", context.GetIp()); + LogContext.PushProperty("User", context.GetUser()); + LogContext.PushProperty("IsSuccessful", statusCode < 400); + LogContext.PushProperty("StatusCode", statusCode); + LogContext.PushProperty("StatusDescription", ((HttpStatusCode)statusCode).ToString()); + LogContext.PushProperty("StatusCodeFamily", context.GetStatusCodeFamily(exception)); + LogContext.PushProperty("ProtocolVersion", context.Request.Protocol); + LogContext.PushProperty("ErrorException", exceptionStackTrace); + LogContext.PushProperty("ErrorMessage", exceptionMessage); + LogContext.PushProperty("ResponseContent", await context.GetResponseContent(this.LogConfiguration.BlacklistResponse)); + LogContext.PushProperty("ContentType", context.Response.ContentType); + LogContext.PushProperty("ContentLength", context.GetResponseLength()); + LogContext.PushProperty("ResponseHeaders", context.GetResponseHeaders(this.LogConfiguration.HeaderBlacklist)); + LogContext.PushProperty("Version", this.LogConfiguration.Version); + LogContext.PushProperty("ElapsedMilliseconds", context.GetExecutionTime(this.LogConfiguration.TimeElapsedProperty)); + LogContext.PushProperty("RequestKey", context.GetRequestKey(this.LogConfiguration.RequestKeyProperty)); + LogContext.PushProperty("Environment", Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")); + + if (context.Items.ContainsKey(LogAdditionalInfo._logAdditionalInfoItemKey)) + { + var additionalInfo = (LogAdditionalInfo)context.Items[LogAdditionalInfo._logAdditionalInfoItemKey]; + + if (additionalInfo?.Data != null) + { + foreach (var item in additionalInfo.Data) + { + LogContext.PushProperty(item.Key, item.Value); + } + } + } + + if (exception != null || statusCode >= 500) + { + var errorTitle = this.LogConfiguration.ErrorTitle ?? DefaultErrorTitle; + Log.Logger.Error(errorTitle); + } + else + { + var informationTitle = this.LogConfiguration.InformationTitle ?? DefaultInformationTitle; + Log.Logger.Information(informationTitle); + } + + Log.CloseAndFlush(); + } + + /// + /// Initialize instance + /// + /// + private void SetupCommunicationLogger(LogConfiguration configuration) + { + this.LogConfiguration = configuration ?? new LogConfiguration(); + } + + /// + /// Handle field size + /// + /// + /// + /// + /// + /// + private static string HandleFieldSize(string value, int maxSize, bool required = false, string defaultValue = "????") + { + if (string.IsNullOrWhiteSpace(value) && !required) + { + return null; + } + + if (string.IsNullOrWhiteSpace(value)) + { + value = defaultValue; + } + + if (value.Length > maxSize) + { + return value.Substring(0, maxSize); + } + + return value; + } +} + +public interface ICommunicationLogger +{ + Task LogData(HttpContext context); + Task LogData(HttpContext context, Exception exception); +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/Logging/ConsoleOptions.cs b/src/Scaffolding/Extensions/Logging/ConsoleOptions.cs new file mode 100644 index 0000000..f051f85 --- /dev/null +++ b/src/Scaffolding/Extensions/Logging/ConsoleOptions.cs @@ -0,0 +1,16 @@ +using Serilog.Events; + +namespace Scaffolding.Extensions.Logging; + +public class ConsoleOptions +{ + /// + /// Set if console is enabled + /// + public bool Enabled { get; set; } + + /// + /// Minimum Level + /// + public LogEventLevel? MinimumLevel { get; set; } +} diff --git a/src/Scaffolding/Extensions/Logging/DisableLoggingExtension.cs b/src/Scaffolding/Extensions/Logging/DisableLoggingExtension.cs new file mode 100644 index 0000000..ac66eff --- /dev/null +++ b/src/Scaffolding/Extensions/Logging/DisableLoggingExtension.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Scaffolding.Extensions.Logging; + +public static class DisableLoggingExtension +{ + internal const string ITEM_NAME = "DisableLogging"; + + public static void DisableLogging(this HttpContext context) + { + context?.Items.Add("DisableLogging", true); + } + + public static void DisableLogging(this ControllerBase controller) + { + controller?.HttpContext?.Items?.Add("DisableLogging", true); + } +} diff --git a/src/Scaffolding/Extensions/Logging/ExceptionMaxLengthExtension.cs b/src/Scaffolding/Extensions/Logging/ExceptionMaxLengthExtension.cs new file mode 100644 index 0000000..1b743ed --- /dev/null +++ b/src/Scaffolding/Extensions/Logging/ExceptionMaxLengthExtension.cs @@ -0,0 +1,23 @@ +namespace Scaffolding.Extensions.Logging; + +public static class ExceptionMaxLengthExtension +{ + public readonly static int ErrorMessageLength = 256; + + public readonly static int ErrorExceptionLength = 1024; + + static ExceptionMaxLengthExtension() + { + var errorMessageMaxLength = Environment.GetEnvironmentVariable("SERILOG_ERROR_MESSAGE_MAX_LENGTH"); + if (!string.IsNullOrWhiteSpace(errorMessageMaxLength)) + { + _ = int.TryParse(errorMessageMaxLength, out ErrorMessageLength); + } + + var errorExceptionMaxLength = Environment.GetEnvironmentVariable("SERILOG_ERROR_EXCEPTION_MAX_LENGTH"); + if (!string.IsNullOrWhiteSpace(errorExceptionMaxLength)) + { + _ = int.TryParse(errorExceptionMaxLength, out ErrorExceptionLength); + } + } +} diff --git a/src/Scaffolding/Extensions/Logging/LogAdditionalInfo.cs b/src/Scaffolding/Extensions/Logging/LogAdditionalInfo.cs new file mode 100644 index 0000000..734839a --- /dev/null +++ b/src/Scaffolding/Extensions/Logging/LogAdditionalInfo.cs @@ -0,0 +1,13 @@ +namespace Scaffolding.Extensions.Logging; + +public class LogAdditionalInfo +{ + internal static readonly string _logAdditionalInfoItemKey = "LogAdditionalInfo"; + + public LogAdditionalInfo(IHttpContextAccessor httpContextAccessor) + { + httpContextAccessor.HttpContext.Items.Add(_logAdditionalInfoItemKey, this); + } + + public Dictionary Data { get; set; } = new Dictionary(); +} diff --git a/src/Scaffolding/Extensions/Logging/LogConfiguration.cs b/src/Scaffolding/Extensions/Logging/LogConfiguration.cs new file mode 100644 index 0000000..a14e94b --- /dev/null +++ b/src/Scaffolding/Extensions/Logging/LogConfiguration.cs @@ -0,0 +1,16 @@ +namespace Scaffolding.Extensions.Logging; + +public class LogConfiguration +{ + public string[] BlacklistRequest { get; set; } + public string[] BlacklistResponse { get; set; } + public string[] HeaderBlacklist { get; set; } + public string[] QueryStringBlacklist { get; set; } + public string[] HttpContextBlacklist { get; set; } + public string InformationTitle { get; set; } + public string ErrorTitle { get; set; } + public string RequestKeyProperty { get; set; } + public string TimeElapsedProperty { get; set; } + public string Version { get; set; } + public string[] IgnoredRoutes { get; set; } +} diff --git a/src/Scaffolding/Extensions/Logging/LogMiddleware.cs b/src/Scaffolding/Extensions/Logging/LogMiddleware.cs new file mode 100644 index 0000000..404c5d6 --- /dev/null +++ b/src/Scaffolding/Extensions/Logging/LogMiddleware.cs @@ -0,0 +1,46 @@ +namespace Scaffolding.Extensions.Logging; + +public class LogMiddleware +{ + private readonly RequestDelegate Next; + + private readonly ICommunicationLogger CommunicationLogger; + + public LogMiddleware( + RequestDelegate next, + ICommunicationLogger communicationLogger) + { + this.Next = next; + this.CommunicationLogger = communicationLogger; + } + + public async Task Invoke(HttpContext context) + { + var originalResponse = context.Response.Body; + context.Response.Body = new MemoryStream(); + + await this.Next(context); + + if (context.Items.ContainsKey("Exception")) + { + var exception = (Exception)context.Items["Exception"]; + await this.CommunicationLogger.LogData(context, exception); + } + else + { + await this.CommunicationLogger.LogData(context); + } + + context.Response.Body.Seek(0, SeekOrigin.Begin); + await context.Response.Body.CopyToAsync(originalResponse); + } + +} + +public static class LogMiddlewareExtension +{ + public static void UseScaffoldingSerilog(this IApplicationBuilder app) + { + app.UseMiddleware(); + } +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/Logging/LogSettings.cs b/src/Scaffolding/Extensions/Logging/LogSettings.cs new file mode 100644 index 0000000..f6bf23a --- /dev/null +++ b/src/Scaffolding/Extensions/Logging/LogSettings.cs @@ -0,0 +1,32 @@ +namespace Scaffolding.Extensions.Logging; + +public class LogSettings +{ + public bool DebugEnabled { get; set; } + public string TitlePrefix { get; set; } + public string[] JsonBlacklistRequest { get; set; } + public string[] JsonBlacklistResponse { get; set; } + public string[] HeaderBlacklist { get; set; } + public string[] HttpContextBlacklist { get; set; } + public string[] QueryStringBlacklist { get; set; } + public string InformationTitle { get; set; } + public string ErrorTitle { get; set; } + public List IgnoredRoutes { get; set; } = new(); + public ConsoleOptions Console { get; set; } + + public string GetInformationTitle() + { + if (string.IsNullOrWhiteSpace(InformationTitle)) + return CommunicationLogger.DefaultInformationTitle; + + return InformationTitle; + } + + public string GetErrorTitle() + { + if (string.IsNullOrWhiteSpace(ErrorTitle)) + return CommunicationLogger.DefaultErrorTitle; + + return ErrorTitle; + } +} diff --git a/src/Scaffolding/Extensions/Logging/LoggingServiceExtension.cs b/src/Scaffolding/Extensions/Logging/LoggingServiceExtension.cs new file mode 100644 index 0000000..c6da35f --- /dev/null +++ b/src/Scaffolding/Extensions/Logging/LoggingServiceExtension.cs @@ -0,0 +1,148 @@ +using Scaffolding.Extensions.Docs; +using Scaffolding.Extensions.Healthcheck; +using Scaffolding.Extensions.RequestKey; +using Scaffolding.Extensions.TimeElapsed; +using Scaffolding.Models; +using Serilog; +using Serilog.Debugging; +using Serilog.Events; + +namespace Scaffolding.Extensions.Logging; + +public static class LoggingServiceExtension +{ + public static void SetupScaffoldingSerilog( + this WebApplicationBuilder builder, + Serilog.ILogger customLogger = null) + { + var apiSettings = builder.Configuration.GetSection("ApiSettings").Get(); + var logSettings = builder.Configuration.GetSection("LogSettings").Get(); + var healthcheckSettings = builder.Configuration.GetSection("HealthcheckSettings").Get(); + + builder.Services.AddSingleton(logSettings); + + if (string.IsNullOrWhiteSpace(apiSettings.Domain)) + { + throw new Exception("Missing 'Domain' configuration."); + } + + if (string.IsNullOrWhiteSpace(apiSettings.Name)) + { + throw new Exception("Missing 'Name' configuration."); + } + + var outputConfiguration = new OutputConfiguration() + { + MinimumLevel = LogEventLevel.Debug, + Console = logSettings.Console + }; + AddOverrideMinimumLevel(outputConfiguration); + AddEnrichProperty(outputConfiguration, apiSettings); + EnableDebug(logSettings, outputConfiguration); + + var loggerConfiguration = new LoggerConfiguration(); + + if (customLogger != null) + { + loggerConfiguration.WriteTo.Logger(customLogger); + } + + loggerConfiguration + .Enrich.FromLogContext() + .Enrich.WithMachineName() + .Enrich.WithEnvironmentUserName() + .Enrich.WithThreadName() + .Enrich.WithThreadId() + .WriteTo.Console(outputConfiguration.MinimumLevel) + .Destructure.ToMaximumDepth(15); + + OverrideMinimumLevel(loggerConfiguration, outputConfiguration); + EnrichProperties(loggerConfiguration, outputConfiguration); + IgnoreRoutes(apiSettings, logSettings, healthcheckSettings); + + Log.Logger = loggerConfiguration.CreateLogger(); + + var config = new LogConfiguration + { + Version = apiSettings?.BuildVersion, + InformationTitle = $"{logSettings?.TitlePrefix}{logSettings?.GetInformationTitle()}", + ErrorTitle = $"{logSettings?.TitlePrefix}{logSettings?.GetErrorTitle()}", + BlacklistRequest = logSettings?.JsonBlacklistRequest, + BlacklistResponse = logSettings?.JsonBlacklistResponse, + HeaderBlacklist = logSettings?.HeaderBlacklist, + HttpContextBlacklist = logSettings?.HttpContextBlacklist, + QueryStringBlacklist = logSettings?.QueryStringBlacklist, + RequestKeyProperty = RequestKeyServiceExtension.RequestKeyHeaderName, + TimeElapsedProperty = TimeElapsedServiceExtension.TimeElapsedHeaderName, + IgnoredRoutes = logSettings?.IgnoredRoutes.ToArray() + }; + + builder.Services.AddScoped(x => new LogAdditionalInfo(x.GetService())); + builder.Services.AddSingleton((Func)(x => new CommunicationLogger(config))); + } + + private static void AddOverrideMinimumLevel(OutputConfiguration outputConfiguration) + { + outputConfiguration.OverrideMinimumLevel["Microsoft"] = LogEventLevel.Warning; + outputConfiguration.OverrideMinimumLevel["System"] = LogEventLevel.Error; + } + + private static void AddEnrichProperty(OutputConfiguration outputConfiguration, ApiSettings apiSettings) + { + outputConfiguration.EnrichProperties["Domain"] = apiSettings.Domain; + outputConfiguration.EnrichProperties["Application"] = apiSettings.Name; + } + + private static void EnableDebug(LogSettings logSettings, OutputConfiguration outputConfiguration) + { + if (logSettings?.DebugEnabled == true) + { + logSettings.Console.MinimumLevel = outputConfiguration.MinimumLevel; + SelfLog.Enable(delegate (string msg) + { + msg = "==== Serilog Debug ==== \n" + msg + "\n======================="; + Console.WriteLine(msg); + }); + } + } + + private static void OverrideMinimumLevel(LoggerConfiguration loggerConfiguration, OutputConfiguration outputConfiguration) + { + loggerConfiguration.MinimumLevel.Is(outputConfiguration.MinimumLevel); + foreach (var item in outputConfiguration.OverrideMinimumLevel) + { + loggerConfiguration.MinimumLevel.Override(item.Key, item.Value); + } + } + + private static void EnrichProperties(LoggerConfiguration loggerConfiguration, OutputConfiguration outputConfiguration) + { + foreach (var enrichProperty in outputConfiguration.EnrichProperties) + { + loggerConfiguration.Enrich.WithProperty(enrichProperty.Key, enrichProperty.Value); + } + } + + private static void IgnoreRoutes( + ApiSettings apiSettings, + LogSettings logSettings, + HealthcheckSettings healthcheckSettings) + { + List ignoredRoutes; + if (DocsServiceExtension.DocsSettings == null) + { + ignoredRoutes = new List(); + } + else + { + ignoredRoutes = DocsServiceExtension.DocsSettings.GetDocsFinalRoutes().ToList(); + } + + if (healthcheckSettings.LogEnabled == false) + { + ignoredRoutes.Add(HealthcheckMiddleware.GetFullPath(apiSettings, healthcheckSettings)); + } + + logSettings.IgnoredRoutes.AddRange(ignoredRoutes); + } +} diff --git a/src/Scaffolding/Extensions/Logging/OutputConfiguration.cs b/src/Scaffolding/Extensions/Logging/OutputConfiguration.cs new file mode 100644 index 0000000..a4043a9 --- /dev/null +++ b/src/Scaffolding/Extensions/Logging/OutputConfiguration.cs @@ -0,0 +1,41 @@ +using Serilog.Events; + +namespace Scaffolding.Extensions.Logging; + +internal class OutputConfiguration +{ + /// + /// Override namespace minimum level + /// + public Dictionary OverrideMinimumLevel { get; set; } + + /// + /// Enrich Properties + /// + public Dictionary EnrichProperties { get; set; } + + /// + /// Console + /// + public ConsoleOptions Console { get; set; } + + /// + /// Minimum level + /// + public LogEventLevel MinimumLevel { get; set; } + + /// + /// Enable enrich by environment and context + /// + public bool EnableEnrichWithEnvironment { get; set; } + + /// + /// Constructor + /// + public OutputConfiguration() + { + this.Console = new ConsoleOptions(); + this.OverrideMinimumLevel = new Dictionary(); + this.EnrichProperties = new Dictionary(); + } +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/QueryFormatter/PathFormatterSettings.cs b/src/Scaffolding/Extensions/QueryFormatter/PathFormatterSettings.cs new file mode 100644 index 0000000..a7b58f9 --- /dev/null +++ b/src/Scaffolding/Extensions/QueryFormatter/PathFormatterSettings.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Scaffolding.Extensions.Json; +using Scaffolding.Utilities; + +namespace Scaffolding.Extensions.QueryFormatter; + +public static class PathFormatterSettings +{ + public static void AddPathFormatter(this MvcOptions mvcOptions, JsonSerializerEnum jsonSerializer) + { + PathValueProvider.JsonSerializerMode = jsonSerializer; + mvcOptions.ValueProviderFactories.Add(new PathValueProviderFactory()); + } +} + +public class PathValueProvider : RouteValueProvider, IValueProvider +{ + public static JsonSerializerEnum JsonSerializerMode { get; set; } + + public PathValueProvider( + BindingSource bindingSource, + RouteValueDictionary values, + System.Globalization.CultureInfo culture) + : base(bindingSource, values, culture) + { + } + + public override bool ContainsPrefix(string prefix) + { + if (prefix == null) return false; + + return base.ContainsPrefix(prefix.GetValueConsideringCurrentCase()); + } + + public override ValueProviderResult GetValue(string key) + { + return base.GetValue(key.GetValueConsideringCurrentCase()); + } +} + +public class PathValueProviderFactory : IValueProviderFactory +{ + public Task CreateValueProviderAsync(ValueProviderFactoryContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var valueProvider = new PathValueProvider( + BindingSource.Query, + context.ActionContext.HttpContext.GetRouteData().Values, + System.Globalization.CultureInfo.CurrentCulture); + + context.ValueProviders.Add(valueProvider); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/QueryFormatter/QueryFormatterSettings.cs b/src/Scaffolding/Extensions/QueryFormatter/QueryFormatterSettings.cs new file mode 100644 index 0000000..8e3dc9f --- /dev/null +++ b/src/Scaffolding/Extensions/QueryFormatter/QueryFormatterSettings.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc; +using Scaffolding.Extensions.Json; +using Scaffolding.Utilities; + +namespace Scaffolding.Extensions.QueryFormatter; + +public static class QueryFormatterSettings +{ + public static void AddQueryFormatter(this MvcOptions mvcOptions, JsonSerializerEnum jsonSerializer) + { + CaseQueryValueProvider.JsonSerializerMode = jsonSerializer; + mvcOptions.ValueProviderFactories.Add(new CaseQueryValueProviderFactory()); + } +} + +public class CaseQueryValueProvider : QueryStringValueProvider, IValueProvider +{ + public static JsonSerializerEnum JsonSerializerMode { get; set; } + + public CaseQueryValueProvider( + BindingSource bindingSource, + IQueryCollection values, + System.Globalization.CultureInfo culture) + : base(bindingSource, values, culture) + { + } + + public override bool ContainsPrefix(string prefix) + { + if (prefix == null) return false; + + return base.ContainsPrefix(prefix.GetValueConsideringCurrentCase()); + } + + public override ValueProviderResult GetValue(string key) + { + return base.GetValue(key.GetValueConsideringCurrentCase()); + } + +} + +public class CaseQueryValueProviderFactory : IValueProviderFactory +{ + public Task CreateValueProviderAsync(ValueProviderFactoryContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var valueProvider = new CaseQueryValueProvider( + BindingSource.Query, + context.ActionContext.HttpContext.Request.Query, + System.Globalization.CultureInfo.CurrentCulture); + + context.ValueProviders.Add(valueProvider); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/RateLimiting/RateLimitingServiceExtension.cs b/src/Scaffolding/Extensions/RateLimiting/RateLimitingServiceExtension.cs new file mode 100644 index 0000000..4f52d12 --- /dev/null +++ b/src/Scaffolding/Extensions/RateLimiting/RateLimitingServiceExtension.cs @@ -0,0 +1,90 @@ +using AspNetCoreRateLimit; +using Microsoft.Extensions.Caching.Memory; + +namespace Scaffolding.Extensions.RateLimiting; + +public static class RateLimitingServiceExtension +{ + public static void SetupRateLimiting(this WebApplicationBuilder builder, RateLimitConfiguration customRateLimitingConfiguration = null) + { + var rateLimitingSettings = builder.Configuration.GetSection("RateLimitingSettings").Get(); + + if (rateLimitingSettings?.Enabled == false) { return; } + + builder.Services.AddSingleton(rateLimitingSettings); + + if (IpRateLimitingIsConfigured()) + { + builder.Services.Configure(builder.Configuration.GetSection("RateLimitingSettings:IpRateLimiting")); + builder.Services.Configure(builder.Configuration.GetSection("RateLimitingSettings:IpRateLimitPolicies")); + builder.Services.AddSingleton(); + } + + if (ClientRateLimitingIsConfigured()) + { + builder.Services.Configure(builder.Configuration.GetSection("RateLimitingSettings:ClientRateLimiting")); + builder.Services.Configure(builder.Configuration.GetSection("RateLimitingSettings:ClientRateLimitingPolicies")); + builder.Services.AddSingleton(); + } + + if (rateLimitingSettings.Storage == RateLimitingStorageType.Distributed) + { + builder.Services.AddSingleton(); + + if (IpRateLimitingIsConfigured()) + { + builder.Services.AddSingleton(); + } + + if (ClientRateLimitingIsConfigured()) + { + builder.Services.AddSingleton(); + } + } + else if (rateLimitingSettings.Storage == RateLimitingStorageType.Memory) + { + builder.Services.AddSingleton(); + + if (IpRateLimitingIsConfigured()) + { + builder.Services.AddSingleton(); + } + + if (ClientRateLimitingIsConfigured()) + { + builder.Services.AddSingleton(); + } + } + + builder.Services.AddSingleton(); + + if (customRateLimitingConfiguration != null) + { + builder.Services.AddSingleton(customRateLimitingConfiguration); + } + + bool IpRateLimitingIsConfigured() + => rateLimitingSettings.IpRateLimiting is not null; + + bool ClientRateLimitingIsConfigured() + => rateLimitingSettings.ClientRateLimiting is not null; + } + + public static void UseScaffoldingRateLimiting(this WebApplication app) + { + var rateLimitingSettings = app.Services.GetService(); + + if (rateLimitingSettings?.Enabled == true) + { + if (rateLimitingSettings.IpRateLimiting is not null) + { + app.UseIpRateLimiting(); + } + + if (rateLimitingSettings.ClientRateLimiting is not null) + { + app.UseClientRateLimiting(); + } + } + } +} diff --git a/src/Scaffolding/Extensions/RateLimiting/RateLimitingSettings.cs b/src/Scaffolding/Extensions/RateLimiting/RateLimitingSettings.cs new file mode 100644 index 0000000..77f8d13 --- /dev/null +++ b/src/Scaffolding/Extensions/RateLimiting/RateLimitingSettings.cs @@ -0,0 +1,11 @@ +using AspNetCoreRateLimit; + +namespace Scaffolding.Extensions.RateLimiting; + +internal class RateLimitingSettings +{ + public bool Enabled { get; set; } + public RateLimitingStorageType Storage { get; set; } = RateLimitingStorageType.Memory; + public IpRateLimitOptions IpRateLimiting { get; set; } + public ClientRateLimitOptions ClientRateLimiting { get; set; } +} diff --git a/src/Scaffolding/Extensions/RateLimiting/RateLimitingStorageType.cs b/src/Scaffolding/Extensions/RateLimiting/RateLimitingStorageType.cs new file mode 100644 index 0000000..4c7d111 --- /dev/null +++ b/src/Scaffolding/Extensions/RateLimiting/RateLimitingStorageType.cs @@ -0,0 +1,7 @@ +namespace Scaffolding.Extensions.RateLimiting; + +internal enum RateLimitingStorageType +{ + Memory, + Distributed +} diff --git a/src/Scaffolding/Extensions/RequestKey/RequestKey.cs b/src/Scaffolding/Extensions/RequestKey/RequestKey.cs new file mode 100644 index 0000000..75eeeb2 --- /dev/null +++ b/src/Scaffolding/Extensions/RequestKey/RequestKey.cs @@ -0,0 +1,13 @@ +namespace Scaffolding.Extensions.RequestKey; + +public class RequestKey +{ + public string Value { get; set; } + + public RequestKey() { } + + public RequestKey(string value) + { + Value = value; + } +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/RequestKey/RequestKeyMiddleware.cs b/src/Scaffolding/Extensions/RequestKey/RequestKeyMiddleware.cs new file mode 100644 index 0000000..908c377 --- /dev/null +++ b/src/Scaffolding/Extensions/RequestKey/RequestKeyMiddleware.cs @@ -0,0 +1,42 @@ +namespace Scaffolding.Extensions.RequestKey; + +internal class RequestKeyMiddleware : IMiddleware +{ + private readonly RequestKey _requestKey; + + public RequestKeyMiddleware(RequestKey requestKey) + { + _requestKey = requestKey; + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + try + { + context.Request.EnableBuffering(); + } + catch (Exception) { } + + if (context.Request.Headers.ContainsKey(RequestKeyServiceExtension.RequestKeyHeaderName)) + { + _requestKey.Value = context.Request.Headers[RequestKeyServiceExtension.RequestKeyHeaderName]; + } + else + { + _requestKey.Value = Guid.NewGuid().ToString(); + } + + context.Items.Add(RequestKeyServiceExtension.RequestKeyHeaderName, _requestKey.Value); + context.Response.Headers.Add(RequestKeyServiceExtension.RequestKeyHeaderName, _requestKey.Value); + + await next(context); + } +} + +internal static class RequestKeyMiddlewareExtension +{ + public static void UseRequestKey(this IApplicationBuilder app) + { + app.UseMiddleware(); + } +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/RequestKey/RequestKeyServiceExtension.cs b/src/Scaffolding/Extensions/RequestKey/RequestKeyServiceExtension.cs new file mode 100644 index 0000000..4466b07 --- /dev/null +++ b/src/Scaffolding/Extensions/RequestKey/RequestKeyServiceExtension.cs @@ -0,0 +1,17 @@ +namespace Scaffolding.Extensions.RequestKey; + +internal static class RequestKeyServiceExtension +{ + internal static string RequestKeyHeaderName = "RequestKey"; + + public static void SetupRequestKey(this WebApplicationBuilder builder, string headerName = null) + { + if (string.IsNullOrWhiteSpace(headerName) == false) + { + RequestKeyHeaderName = headerName; + } + + builder.Services.AddScoped(); + builder.Services.AddScoped(x => new RequestKey()); + } +} diff --git a/src/Scaffolding/Extensions/RoutePrefix/IgnoreRoutePrefixAttribute.cs b/src/Scaffolding/Extensions/RoutePrefix/IgnoreRoutePrefixAttribute.cs new file mode 100644 index 0000000..671d324 --- /dev/null +++ b/src/Scaffolding/Extensions/RoutePrefix/IgnoreRoutePrefixAttribute.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Scaffolding.Extensions.RoutePrefix; + +public class IgnoreRoutePrefixAttribute : Attribute, IActionFilter, IFilterMetadata +{ + public bool AllowMultiple => false; + + public void OnActionExecuted(ActionExecutedContext context) + { + } + + public void OnActionExecuting(ActionExecutingContext context) + { + } +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/RoutePrefix/RoutePrefixConvention.cs b/src/Scaffolding/Extensions/RoutePrefix/RoutePrefixConvention.cs new file mode 100644 index 0000000..2620120 --- /dev/null +++ b/src/Scaffolding/Extensions/RoutePrefix/RoutePrefixConvention.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace Scaffolding.Extensions.RoutePrefix; + +public class RoutePrefixConvention : IApplicationModelConvention +{ + private readonly AttributeRouteModel CentralPrefix; + + public RoutePrefixConvention(IRouteTemplateProvider routeTemplateProvider) + { + CentralPrefix = new AttributeRouteModel(routeTemplateProvider); + } + + public void Apply(ApplicationModel application) + { + foreach (var controller in application.Controllers) + { + var matchedSelectors = controller.Selectors.Where(x => x.AttributeRouteModel != null).ToList(); + if (matchedSelectors.Any()) + { + foreach (var selectorModel in matchedSelectors) + { + if (selectorModel.EndpointMetadata.Any(r => r.GetType() == typeof(IgnoreRoutePrefixAttribute))) + { + continue; + } + + selectorModel.AttributeRouteModel = AttributeRouteModel.CombineAttributeRouteModel(CentralPrefix, + selectorModel.AttributeRouteModel); + } + } + + var unmatchedSelectors = controller.Selectors.Where(x => x.AttributeRouteModel == null).ToList(); + if (unmatchedSelectors.Any()) + { + foreach (var selectorModel in unmatchedSelectors) + { + if (selectorModel.EndpointMetadata.Any(r => r.GetType() == typeof(IgnoreRoutePrefixAttribute))) + { + continue; + } + + selectorModel.AttributeRouteModel = CentralPrefix; + } + } + } + } +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/RoutePrefix/RoutePrefixExtensions.cs b/src/Scaffolding/Extensions/RoutePrefix/RoutePrefixExtensions.cs new file mode 100644 index 0000000..aa2238a --- /dev/null +++ b/src/Scaffolding/Extensions/RoutePrefix/RoutePrefixExtensions.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Scaffolding.Extensions.RoutePrefix; + +public static class RoutePrefixExtensions +{ + public static void UseCentralRoutePrefix(this MvcOptions mvcOptions, string pathPrefix) + { + var routeAttribute = new RouteAttribute(pathPrefix ?? ""); + mvcOptions.Conventions.Insert(0, new RoutePrefixConvention(routeAttribute)); + } +} \ No newline at end of file diff --git a/src/Scaffolding/Extensions/TimeElapsed/TimeElapsedMiddleware.cs b/src/Scaffolding/Extensions/TimeElapsed/TimeElapsedMiddleware.cs new file mode 100644 index 0000000..48c2357 --- /dev/null +++ b/src/Scaffolding/Extensions/TimeElapsed/TimeElapsedMiddleware.cs @@ -0,0 +1,41 @@ +using System.Diagnostics; + +namespace Scaffolding.Extensions.TimeElapsed; + +internal class TimeElapsedMiddleware +{ + private readonly RequestDelegate Next; + + public TimeElapsedMiddleware(RequestDelegate next) + { + Next = next; + } + + public async Task Invoke(HttpContext context) + { + var stopwatch = new Stopwatch(); + string timeIsMs = "-1"; + + context.Response.OnStarting(() => + { + context.Response.Headers[TimeElapsedServiceExtension.TimeElapsedHeaderName] = timeIsMs; + return Task.CompletedTask; + }); + + stopwatch.Start(); + + await Next(context); + + stopwatch.Stop(); + timeIsMs = stopwatch.ElapsedMilliseconds.ToString(); + context.Items[TimeElapsedServiceExtension.TimeElapsedHeaderName] = timeIsMs; + } +} + +internal static class TimeElapsedMiddlewareExtension +{ + public static void UseTimeElapsed(this IApplicationBuilder app) + { + app.UseMiddleware(); + } +} diff --git a/src/Scaffolding/Extensions/TimeElapsed/TimeElapsedServiceExtension.cs b/src/Scaffolding/Extensions/TimeElapsed/TimeElapsedServiceExtension.cs new file mode 100644 index 0000000..3fd89ad --- /dev/null +++ b/src/Scaffolding/Extensions/TimeElapsed/TimeElapsedServiceExtension.cs @@ -0,0 +1,14 @@ +namespace Scaffolding.Extensions.TimeElapsed; + +internal static class TimeElapsedServiceExtension +{ + internal static string TimeElapsedHeaderName = "X-Internal-Time"; + + public static void SetupTimeElapsed(this WebApplicationBuilder _, string headerName = null) + { + if (string.IsNullOrWhiteSpace(headerName) == false) + { + TimeElapsedHeaderName = headerName; + } + } +} diff --git a/src/Scaffolding/Models/ApiSettings.cs b/src/Scaffolding/Models/ApiSettings.cs new file mode 100644 index 0000000..a3fd543 --- /dev/null +++ b/src/Scaffolding/Models/ApiSettings.cs @@ -0,0 +1,46 @@ +using Scaffolding.Extensions.Json; +using TimeZoneConverter; + +namespace Scaffolding.Models +{ + public class ApiSettings + { + public string Name { get; set; } + public int Port { get; set; } + public string EnvironmentVariablesPrefix { get; set; } + public string Version { get; set; } + public string PathBase { get; set; } + public string Domain { get; set; } + public string BuildVersion { get; set; } + public string[] SupportedCultures { get; set; } + public string RequestKeyProperty { get; set; } + public string TimeElapsedProperty { get; set; } + public string TimezoneHeader { get; set; } + public string TimezoneDefault { get; set; } + public JsonSerializerEnum JsonSerializer { get; set; } + public TimeZoneInfo TimezoneDefaultInfo => TZConvert.GetTimeZoneInfo(TimezoneDefault); + + public ApiSettings() + { + Name = "DefaultApp"; + Port = 8000; + Domain = "DefaultDomain"; + BuildVersion = "1.0.0"; + } + + public string GetFullPath() + { + if (!PathBase.StartsWith('/')) + { + PathBase = $"/{PathBase}"; + } + + if (PathBase.Contains("{version}", StringComparison.OrdinalIgnoreCase)) + { + return PathBase.Replace("{version}", Version, StringComparison.OrdinalIgnoreCase); + } + return PathBase + (!string.IsNullOrEmpty(Version) ? $"/{Version}" : ""); + } + } + +} diff --git a/src/Scaffolding/Models/ExceptionContainer.cs b/src/Scaffolding/Models/ExceptionContainer.cs new file mode 100644 index 0000000..0bc5a63 --- /dev/null +++ b/src/Scaffolding/Models/ExceptionContainer.cs @@ -0,0 +1,11 @@ +namespace Scaffolding.Models; + +internal class ExceptionContainer +{ + public Exception Exception { get; set; } + + public ExceptionContainer(Exception exception) + { + Exception = exception; + } +} \ No newline at end of file diff --git a/src/Scaffolding/Models/ScaffoldingException.cs b/src/Scaffolding/Models/ScaffoldingException.cs new file mode 100644 index 0000000..c2691e5 --- /dev/null +++ b/src/Scaffolding/Models/ScaffoldingException.cs @@ -0,0 +1,8 @@ +namespace Scaffolding.Models; + +internal class ScaffoldingException : Exception +{ + public ScaffoldingException(string message) : base(message) + { + } +} diff --git a/src/Scaffolding/Program.cs b/src/Scaffolding/Program.cs new file mode 100644 index 0000000..55bd1b5 --- /dev/null +++ b/src/Scaffolding/Program.cs @@ -0,0 +1,27 @@ +using Scaffolding; +using Scaffolding.Extensions.Healthcheck; + +var builder = Api.Initialize(args); + +builder.UseLogging(); + +var healthcheckBuilder = builder.SetupHealthcheck(); + +// Example +void AddHealthchecks() +{ + // var externalService (Service instance after configuration and injection) + // healthcheckBuilder.AddUrlGroup(new Uri(externalService.Name), name: "External Service Name"); + + healthcheckBuilder.AddUrlGroup(new Uri("https://www.google.com.br"), name: "Google", tags: new[] { "external" }); +} + +AddHealthchecks(); + +builder.Services.AddMemoryCache(); + +var app = builder.Build(); + +app.UseDefaultConfiguration(); + +await Api.RunAsync(app); \ No newline at end of file diff --git a/src/Scaffolding/Properties/launchSettings.json b/src/Scaffolding/Properties/launchSettings.json new file mode 100644 index 0000000..9da8251 --- /dev/null +++ b/src/Scaffolding/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Scaffolding": { + "commandName": "Project", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/src/Scaffolding/Scaffolding.csproj b/src/Scaffolding/Scaffolding.csproj new file mode 100644 index 0000000..881257a --- /dev/null +++ b/src/Scaffolding/Scaffolding.csproj @@ -0,0 +1,42 @@ + + + + A scaffolding lib to handle common code for a basic application. + A scaffolding lib to handle common code for a basic application. + net6 + Scaffolding + Scaffolding + https://github.com/eduardosbcabral/pipelineRD/assets/29133996/83d3b8fd-4403-4351-b224-f3a6d7debd3a + README.md + Eduardo Cabral and Thiago Barradas + https://github.com/eduardosbcabral/aspnet-scaffolding + true + enable + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Scaffolding/Utilities/CaseUtility.cs b/src/Scaffolding/Utilities/CaseUtility.cs new file mode 100644 index 0000000..381a32e --- /dev/null +++ b/src/Scaffolding/Utilities/CaseUtility.cs @@ -0,0 +1,18 @@ +using Scaffolding.Extensions.Json; + +namespace Scaffolding.Utilities; + +public static class CaseUtility +{ + public static JsonSerializerEnum JsonSerializerMode { get; set; } + + public static string GetValueConsideringCurrentCase(this string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + return value.ToCase(JsonSerializerMode.ToString()); + } +} diff --git a/src/Scaffolding/Utilities/Converters/DateTimeConverter.cs b/src/Scaffolding/Utilities/Converters/DateTimeConverter.cs new file mode 100644 index 0000000..79c6212 --- /dev/null +++ b/src/Scaffolding/Utilities/Converters/DateTimeConverter.cs @@ -0,0 +1,122 @@ +using Newtonsoft.Json; +using TimeZoneConverter; + +namespace Scaffolding.Utilities.Converters; + +/// +/// DateTime and NullableDataTime converter for newtonsoft +/// Considering time zone and only date format +/// +internal class DateTimeConverter : JsonConverter +{ + public static TimeZoneInfo DefaultTimeZone = TZConvert.GetTimeZoneInfo("E. South America Standard Time"); + + public Func GetTimeZoneInfo { get; set; } + + public DateTimeConverter() { } + + public DateTimeConverter(Func getTimeZoneInfo) + { + this.GetTimeZoneInfo = getTimeZoneInfo; + } + + public override bool CanConvert(Type objectType) + { + return + (typeof(DateTime).IsAssignableFrom(objectType)) || + (typeof(DateTime?).IsAssignableFrom(objectType)); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (string.IsNullOrWhiteSpace(reader.Value?.ToString())) + { + return null; + } + + var date = DateTime.Parse(reader.Value.ToString()); + + date = TimeZoneInfo.ConvertTimeToUtc(date, GetCurrentTimeZoneInfoInvokingFunction()); + + return date; + } + + public override async void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + DateTime? convertedDate = null; + + if (value != null) + { + var originalDate = DateTime.Parse(value.ToString()); + + convertedDate = TimeZoneInfo.ConvertTimeFromUtc(originalDate, GetCurrentTimeZoneInfoInvokingFunction()); + } + + await writer.WriteValueAsync(convertedDate); + } + + public static TimeZoneInfo GetTimeZoneByAspNetHeader(IHttpContextAccessor httpContextAccessor, string headerName) + { + var httpContext = httpContextAccessor.HttpContext; + + try + { + var timezone = httpContext.Request.Headers[headerName]; + return TZConvert.GetTimeZoneInfo(timezone); + } + catch (Exception) + { + return DefaultTimeZone; + } + } + + private TimeZoneInfo GetCurrentTimeZoneInfoInvokingFunction() + { + return this.GetTimeZoneInfo?.Invoke() ?? DefaultTimeZone; + } +} + +internal class DateConverter : JsonConverter +{ + private readonly static string _defaultFormat = "yyyy-MM-dd"; + + public string Format { get; set; } + + public DateConverter() { } + + public DateConverter(string format) + { + this.Format = format; + } + + public override bool CanConvert(Type objectType) + { + return + (typeof(DateTime).IsAssignableFrom(objectType)) || + (typeof(DateTime?).IsAssignableFrom(objectType)); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (string.IsNullOrWhiteSpace(reader.Value?.ToString())) + { + return null; + } + + var date = DateTime.Parse(reader.Value.ToString()); + + return date.Date; + } + + public override async void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + DateTime? convertedDate = null; + + if (value != null) + { + convertedDate = DateTime.Parse(value.ToString()); + } + + await writer.WriteValueAsync(convertedDate?.ToString(this.Format ?? _defaultFormat)); + } +} \ No newline at end of file diff --git a/src/Scaffolding/Utilities/Converters/EnumWithContractJsonConverter.cs b/src/Scaffolding/Utilities/Converters/EnumWithContractJsonConverter.cs new file mode 100644 index 0000000..1a350d7 --- /dev/null +++ b/src/Scaffolding/Utilities/Converters/EnumWithContractJsonConverter.cs @@ -0,0 +1,88 @@ +using Newtonsoft.Json.Serialization; +using Newtonsoft.Json; +using System.Reflection; +using static Scaffolding.Utilities.JsonUtility; + +namespace Scaffolding.Utilities.Converters; + +internal class EnumWithContractJsonConverter : JsonConverter +{ + public static bool IgnoreEnumCase { get; set; } + + public override bool CanConvert(Type objectType) + { + return (IsNullableType(objectType) ? Nullable.GetUnderlyingType(objectType) : objectType).GetTypeInfo().IsEnum; + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + bool flag = IsNullableType(objectType); + Type enumType = (flag ? Nullable.GetUnderlyingType(objectType) : objectType); + string[] names = Enum.GetNames(enumType); + if (reader.TokenType == JsonToken.String) + { + string enumText = reader.Value!.ToString().ToLowerCase(); + if (!string.IsNullOrEmpty(enumText)) + { + string text = names.Where((string n) => string.Equals(n.ToLowerCase(), enumText, StringComparison.OrdinalIgnoreCase)).FirstOrDefault(); + if (text != null) + { + return Enum.Parse(enumType, text); + } + } + } + else if (reader.TokenType == JsonToken.Integer) + { + int value = Convert.ToInt32(reader.Value); + if (((int[])Enum.GetValues(enumType)).Contains(value)) + { + return Enum.Parse(enumType, value.ToString()); + } + } + + if (!flag) + { + string text2 = names.Where((string n) => string.Equals(n, "Undefined", StringComparison.OrdinalIgnoreCase)).FirstOrDefault(); + if (text2 == null) + { + text2 = names.First(); + } + + return Enum.Parse(enumType, text2); + } + + return null; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + string text = value.ToString(); + if (!IgnoreEnumCase) + { + if (serializer.ContractResolver is CamelCasePropertyNamesContractResolver || serializer.ContractResolver is CustomCamelCasePropertyNamesContractResolver) + { + text = text.ToCamelCase(); + } + else if (serializer.ContractResolver is SnakeCasePropertyNamesContractResolver) + { + text = text.ToSnakeCase(); + } + else if (serializer.ContractResolver is LowerCasePropertyNamesContractResolver) + { + text = text.ToLowerCase(); + } + } + + writer.WriteValue(text); + } + + private bool IsNullableType(Type t) + { + if (t.GetTypeInfo().IsGenericType) + { + return t.GetGenericTypeDefinition() == typeof(Nullable<>); + } + + return false; + } +} \ No newline at end of file diff --git a/src/Scaffolding/Utilities/EnvironmentUtility.cs b/src/Scaffolding/Utilities/EnvironmentUtility.cs new file mode 100644 index 0000000..93bc4f6 --- /dev/null +++ b/src/Scaffolding/Utilities/EnvironmentUtility.cs @@ -0,0 +1,13 @@ +namespace Scaffolding.Utilities; + +public class EnvironmentUtility +{ + public static string GetCurrentEnvironment() + => Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; + + public static bool IsDevelopment() + { + var env = GetCurrentEnvironment(); + return env.Contains("dev", StringComparison.InvariantCultureIgnoreCase); + } +} diff --git a/src/Scaffolding/Utilities/Extractors/HttpContextExtractor.cs b/src/Scaffolding/Utilities/Extractors/HttpContextExtractor.cs new file mode 100644 index 0000000..f0b2bb2 --- /dev/null +++ b/src/Scaffolding/Utilities/Extractors/HttpContextExtractor.cs @@ -0,0 +1,399 @@ +using Scaffolding.Extensions.Json; +using System.Security.Claims; +using System.Text; +using System.Web; + +namespace Scaffolding.Utilities.Extractors; + +public static class HttpContextExtractor +{ + /// + /// Get status code + /// + /// + /// + /// + public static int GetStatusCode(this HttpContext context, Exception exception) + { + if (exception != null) + { + return 500; + } + + var statusCode = context?.Response?.StatusCode ?? 0; + return statusCode; + } + + /// + /// Get status code family, like 1XX 2XX 3XX 4XX 5XX + /// + /// + /// + /// + public static string GetStatusCodeFamily(this HttpContext context, Exception exception) + { + var statusCode = context.GetStatusCode(exception); + return statusCode.ToString()[0] + "XX"; + } + + /// + /// Get query string + /// + /// + /// + public static IDictionary GetQueryString(this HttpContext context, string[] blacklist) + { + if (context?.Request?.Query == null) + { + return null; + } + + var dic = new Dictionary(); + foreach (var item in context.Request.Query) + { + var key = item.Key; + var value = item.Value.ToString(); + dic[item.Key] = MaskField(key, value, blacklist); + } + + return dic; + } + + public static string GetRawQueryString(this HttpContext context, string[] blacklist) + { + if (context?.Request?.Query == null) + { + return string.Empty; + } + + var queryString = HttpUtility.ParseQueryString(context.Request.QueryString.ToString()); + foreach (var qs in queryString.AllKeys) + { + var key = qs; + var value = queryString[qs]; + queryString[qs] = MaskField(key, value, blacklist); + } + + return queryString.HasKeys() ? $"?{queryString}" : ""; + } + + /// + /// Get all request headers + /// + /// + /// + public static IDictionary GetRequestHeaders(this HttpContext context, string[] blacklist) + { + if (context?.Request?.Headers == null) + { + return null; + } + + var dic = new Dictionary(); + foreach (var item in context.Request.Headers) + { + var key = item.Key; + var value = item.Value.ToString(); + dic[item.Key] = MaskField(key, value, blacklist); + } + + return dic; + } + + /// + /// Get all response headers + /// + /// + /// + public static IDictionary GetResponseHeaders(this HttpContext context, string[] blacklist) + { + if (context?.Response?.Headers == null) + { + return null; + } + + var dic = new Dictionary(); + foreach (var item in context.Response.Headers) + { + var key = item.Key; + var value = item.Value.ToString(); + dic[item.Key] = MaskField(key, value, blacklist); + } + + return dic; + } + + /// + /// Get total execution time from X-Internal-Time header + /// + /// + /// + public static object GetExecutionTime(this HttpContext context, string timeElapsedProperty) + { + long elapsedDefault = -1; + object elapsedParsed = "-1"; + + context?.Items?.TryGetValue(timeElapsedProperty, out elapsedParsed); + if (Int64.TryParse(elapsedParsed.ToString(), out long elapsedLong) == true) + { + return elapsedLong; + } + + return elapsedDefault; + } + + /// + /// Get request key from RequestKey Header + /// + /// + /// + public static string GetRequestKey(this HttpContext context, string requestKeyProperty) + { + if (string.IsNullOrWhiteSpace(requestKeyProperty)) + { + return null; + } + + if (context?.Items?.ContainsKey(requestKeyProperty) == true) + { + return context.Items[requestKeyProperty].ToString(); + } + + return null; + } + + /// + /// Get ip (X-Forwarded-For or original) + /// + /// + /// + public static string GetIp(this HttpContext context) + { + var defaultIp = "??"; + + if (context?.Request?.Headers == null) + { + return defaultIp; + } + + if (context.Request.Headers.Any(r => r.Key == "X-Forwarded-For") == true) + { + return context.Request.Headers["X-Forwarded-For"].First(); + } + + return context.Connection?.RemoteIpAddress?.ToString() ?? defaultIp; + } + + /// + /// Get user + /// + /// + /// + public static string GetUser(this HttpContext context) + { + return context?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + } + + /// + /// Get request body + /// + /// + /// + public static async Task GetRequestBody(this HttpContext context, string[] blacklist) + { + var request = context?.Request; + + if (request?.Body == null) + { + return null; + } + + var body = ""; + request.EnableBuffering(); + using (var reader = new StreamReader(request.Body, Encoding.UTF8, true, 1024, true)) + { + body = await reader.ReadToEndAsync(); + } + request.Body.Position = 0; + + var contentType = (context.Request.Headers.ContainsKey("Content-Type") == true) + ? string.Join(";", context.Request.Headers["Content-Type"]) + : string.Empty; + + var isJson = contentType.Contains("json") == true; + + if (isJson) + { + return GetContentAsObjectByContentTypeJson(body, true, blacklist); + } + else + { + return new Dictionary { { "raw_body", body } }; + } + } + + /// + /// Get host + /// + /// + /// + public static object GetHost(this HttpContext context) + { + return context?.Request?.Host.ToString().Split(':').FirstOrDefault(); + } + + /// + /// Get port + /// + /// + /// + public static object GetPort(this HttpContext context) + { + var parts = context?.Request?.Host.ToString().Split(':'); + + if (parts.Count() > 1) + { + return parts.LastOrDefault(); + } + + if (context?.Request?.Protocol == "http") + { + return 80; + } + + if (context?.Request?.Protocol == "https") + { + return 443; + } + + return 0; + } + + /// + /// Get port + /// + /// + /// + public static object GetFullUrl(this HttpContext context, string[] queryBlacklist, string[] httpContextBlackList) + { + var absoluteUri = string.Concat( + context?.Request?.Scheme, + "://", + context?.Request?.Host.ToUriComponent(), + context?.GetPathBase(httpContextBlackList), + context?.GetPath(httpContextBlackList), + context.GetRawQueryString(queryBlacklist)); + + return absoluteUri; + } + + /// + /// Get Path + /// + /// + /// + public static object GetPath(this HttpContext context, string[] blacklist) + { + + if (blacklist?.Any() == true && blacklist.Contains("Path")) + { + return @"/******"; + } + + return context.Request.Path.ToUriComponent(); + + } + + /// + /// Get PathBase + /// + /// + /// + public static object GetPathBase(this HttpContext context, string[] blacklist) + { + + if (blacklist?.Any() == true && blacklist.Contains("PathBase")) + { + return @"/******"; + } + + return context.Request.PathBase.ToUriComponent(); + + } + + /// + /// Get response content + /// + /// + /// + public static async Task GetResponseContent(this HttpContext context, string[] blacklist) + { + if (context?.Response?.Body?.CanRead == false) + { + return null; + } + + MemoryStream stream = new(); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + context.Response.Body.CopyTo(stream); + + stream.Seek(0, SeekOrigin.Begin); + var body = string.Empty; + using (StreamReader reader = new(stream)) + { + body = await reader.ReadToEndAsync(); + } + + if (string.IsNullOrWhiteSpace(body) == false && + context.Response.ContentType.Contains("json") == true) + { + return GetContentAsObjectByContentTypeJson(body, true, blacklist); + } + else + { + return new Dictionary { { "raw_content", body } }; + } + } + + /// + /// Get content length + /// + /// + /// + public static long GetResponseLength(this HttpContext context) + { + return context?.Response?.Body?.Length ?? 0; + } + + /// + /// Get content as object by content type + /// + /// + /// + internal static object GetContentAsObjectByContentTypeJson(string content, bool maskJson, string[] backlist) + { + try + { + if (maskJson == true && backlist?.Any() == true) + { + content = content.MaskFields(backlist, "******"); + } + + return content.DeserializeAsObject(); + } + catch (Exception) { } + + return content; + } + + internal static string MaskField(string key, string value, string[] blacklist) + { + if (blacklist?.Any() == true && blacklist.Contains(key)) + { + return "******"; + } + + return value; + } +} diff --git a/src/Scaffolding/Utilities/JsonUtility.cs b/src/Scaffolding/Utilities/JsonUtility.cs new file mode 100644 index 0000000..d7d3285 --- /dev/null +++ b/src/Scaffolding/Utilities/JsonUtility.cs @@ -0,0 +1,338 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using Scaffolding.Utilities.Converters; + +namespace Scaffolding.Utilities; + +public class JsonUtility +{ + private static readonly object Lock = new object(); + + public static List DefaultConverters = new List + { + new EnumWithContractJsonConverter(), + new IsoDateTimeConverter + { + DateTimeFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.ffffff" + } + }; + + private static JsonSerializerSettings _snakeCaseJsonSerializerSettings; + + public static JsonSerializerSettings _camelCaseJsonSerializerSettings; + + private static JsonSerializerSettings _lowerCaseJsonSerializerSettings; + + private static JsonSerializerSettings _originalCaseJsonSerializerSettings; + + private static JsonSerializer _camelCaseJsonSerializer; + + private static JsonSerializer _snakeCaseJsonSerializer; + + private static JsonSerializer _lowerCaseJsonSerializer; + + private static JsonSerializer _originalCaseJsonSerializer; + + public static JsonSerializerSettings SnakeCaseJsonSerializerSettings + { + get + { + if (_snakeCaseJsonSerializerSettings == null) + { + lock (Lock) + { + if (_snakeCaseJsonSerializerSettings == null) + { + JsonSerializerSettings settings = new JsonSerializerSettings(); + settings.ContractResolver = new SnakeCasePropertyNamesContractResolver(); + DefaultConverters.ForEach(delegate (JsonConverter c) + { + settings.Converters.Add(c); + }); + settings.NullValueHandling = NullValueHandling.Ignore; + _snakeCaseJsonSerializerSettings = settings; + } + } + } + + return _snakeCaseJsonSerializerSettings; + } + } + + public static JsonSerializerSettings CamelCaseJsonSerializerSettings + { + get + { + if (_camelCaseJsonSerializerSettings == null) + { + lock (Lock) + { + if (_camelCaseJsonSerializerSettings == null) + { + JsonSerializerSettings settings = new JsonSerializerSettings(); + settings.ContractResolver = new CustomCamelCasePropertyNamesContractResolver(); + DefaultConverters.ForEach(delegate (JsonConverter c) + { + settings.Converters.Add(c); + }); + settings.NullValueHandling = NullValueHandling.Ignore; + _camelCaseJsonSerializerSettings = settings; + } + } + } + + return _camelCaseJsonSerializerSettings; + } + } + + public static JsonSerializerSettings LowerCaseJsonSerializerSettings + { + get + { + if (_lowerCaseJsonSerializerSettings == null) + { + lock (Lock) + { + if (_lowerCaseJsonSerializerSettings == null) + { + JsonSerializerSettings settings = new JsonSerializerSettings(); + settings.ContractResolver = new LowerCasePropertyNamesContractResolver(); + DefaultConverters.ForEach(delegate (JsonConverter c) + { + settings.Converters.Add(c); + }); + settings.NullValueHandling = NullValueHandling.Ignore; + _lowerCaseJsonSerializerSettings = settings; + } + } + } + + return _lowerCaseJsonSerializerSettings; + } + } + + public static JsonSerializerSettings OriginalCaseJsonSerializerSettings + { + get + { + if (_originalCaseJsonSerializerSettings == null) + { + lock (Lock) + { + if (_originalCaseJsonSerializerSettings == null) + { + JsonSerializerSettings settings = new JsonSerializerSettings(); + settings.ContractResolver = new OriginalCasePropertyNamesContractResolver(); + DefaultConverters.ForEach(delegate (JsonConverter c) + { + settings.Converters.Add(c); + }); + settings.NullValueHandling = NullValueHandling.Ignore; + _originalCaseJsonSerializerSettings = settings; + } + } + } + + return _originalCaseJsonSerializerSettings; + } + } + + public static JsonSerializer CamelCaseJsonSerializer + { + get + { + if (_camelCaseJsonSerializer == null) + { + lock (Lock) + { + if (_camelCaseJsonSerializer == null) + { + JsonSerializer serializer = new JsonSerializer(); + serializer.NullValueHandling = NullValueHandling.Ignore; + serializer.ContractResolver = new CustomCamelCasePropertyNamesContractResolver(); + DefaultConverters.ForEach(delegate (JsonConverter c) + { + serializer.Converters.Add(c); + }); + _camelCaseJsonSerializer = serializer; + } + } + } + + return _camelCaseJsonSerializer; + } + } + + public static JsonSerializer SnakeCaseJsonSerializer + { + get + { + if (_snakeCaseJsonSerializer == null) + { + lock (Lock) + { + if (_snakeCaseJsonSerializer == null) + { + JsonSerializer serializer = new JsonSerializer(); + serializer.NullValueHandling = NullValueHandling.Ignore; + serializer.ContractResolver = new SnakeCasePropertyNamesContractResolver(); + DefaultConverters.ForEach(delegate (JsonConverter c) + { + serializer.Converters.Add(c); + }); + _snakeCaseJsonSerializer = serializer; + } + } + } + + return _snakeCaseJsonSerializer; + } + } + + public static JsonSerializer LowerCaseJsonSerializer + { + get + { + if (_lowerCaseJsonSerializer == null) + { + lock (Lock) + { + if (_lowerCaseJsonSerializer == null) + { + JsonSerializer serializer = new JsonSerializer(); + serializer.NullValueHandling = NullValueHandling.Ignore; + serializer.ContractResolver = new LowerCasePropertyNamesContractResolver(); + DefaultConverters.ForEach(delegate (JsonConverter c) + { + serializer.Converters.Add(c); + }); + _lowerCaseJsonSerializer = serializer; + } + } + } + + return _lowerCaseJsonSerializer; + } + } + + public static JsonSerializer OriginalCaseJsonSerializer + { + get + { + if (_originalCaseJsonSerializer == null) + { + lock (Lock) + { + if (_originalCaseJsonSerializer == null) + { + JsonSerializer serializer = new JsonSerializer(); + serializer.NullValueHandling = NullValueHandling.Ignore; + serializer.ContractResolver = new OriginalCasePropertyNamesContractResolver(); + DefaultConverters.ForEach(delegate (JsonConverter c) + { + serializer.Converters.Add(c); + }); + _originalCaseJsonSerializer = serializer; + } + } + } + + return _originalCaseJsonSerializer; + } + } + + /// + /// Resolve property names to lowercase only + /// + public class LowerCaseNamingResolver : NamingStrategy + { + public LowerCaseNamingResolver() + { + this.ProcessDictionaryKeys = true; + this.OverrideSpecifiedNames = true; + } + + protected override string ResolvePropertyName(string name) + { + return name.ToLowerInvariant(); + } + } + + /// + /// Lowercase contract resolver + /// + public class LowerCasePropertyNamesContractResolver : DefaultContractResolver + { + public LowerCasePropertyNamesContractResolver() + { + this.NamingStrategy = new LowerCaseNamingResolver + { + ProcessDictionaryKeys = true, + OverrideSpecifiedNames = true + }; + } + } + + /// + /// Resolve property names original name + /// + public class OriginalCaseNamingResolver : NamingStrategy + { + public OriginalCaseNamingResolver() + { + this.ProcessDictionaryKeys = true; + this.OverrideSpecifiedNames = true; + } + + protected override string ResolvePropertyName(string name) + { + return name; + } + } + + /// + /// Original contract resolver + /// + public class OriginalCasePropertyNamesContractResolver : DefaultContractResolver + { + public OriginalCasePropertyNamesContractResolver() + { + this.NamingStrategy = new OriginalCaseNamingResolver + { + ProcessDictionaryKeys = true, + OverrideSpecifiedNames = true + }; + } + } + + /// + /// Snake case contract resolver + /// + public class SnakeCasePropertyNamesContractResolver : DefaultContractResolver + { + public SnakeCasePropertyNamesContractResolver() + { + this.NamingStrategy = new SnakeCaseNamingStrategy + { + ProcessDictionaryKeys = true, + OverrideSpecifiedNames = true + }; + } + } + + /// + /// Camel case contract resolver + /// + public class CustomCamelCasePropertyNamesContractResolver : DefaultContractResolver + { + public CustomCamelCasePropertyNamesContractResolver() + { + this.NamingStrategy = new CamelCaseNamingStrategy + { + ProcessDictionaryKeys = true, + OverrideSpecifiedNames = true + }; + } + } +} diff --git a/src/Scaffolding/Utilities/StringUtility.cs b/src/Scaffolding/Utilities/StringUtility.cs new file mode 100644 index 0000000..be1888a --- /dev/null +++ b/src/Scaffolding/Utilities/StringUtility.cs @@ -0,0 +1,79 @@ +using System.Text.RegularExpressions; + +namespace Scaffolding.Utilities; + +internal static class StringUtility +{ + public static string ToCase(this string value, string strategy) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + strategy = strategy?.ToLowerInvariant().Trim(); + switch (strategy) + { + case "snake": + case "snakecase": + return value.ToSnakeCase(); + case "camel": + case "camelcase": + return value.ToCamelCase(); + case "lower": + case "lowercase": + return value.ToLowerCase(); + default: + return value; + } + } + + public static string ToSnakeCase(this string text) + { + if (string.IsNullOrEmpty(text)) + { + return null; + } + + text = text.ToCamelCase(); + text = string.Concat(text.Select((char _char, int i) => (i <= 0 || !char.IsUpper(_char)) ? _char.ToString() : ("_" + _char))).ToLower(); + return text; + } + + public static string ToCamelCase(this string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + string text2 = ""; + bool flag = false; + for (int i = 0; i < text.Length - 1; i++) + { + text2 += (flag ? char.ToUpperInvariant(text[i]) : text[i]); + flag = text[i] == '_'; + } + + text2 += text[text.Length - 1]; + text2 = text2.Replace("_", ""); + if (text2.Length == 0) + { + return null; + } + + text2 = Regex.Replace(text2, "([A-Z])([A-Z]+)($|[A-Z])", (Match m) => m.Groups[1].Value + m.Groups[2].Value.ToLower() + m.Groups[3].Value); + return char.ToLowerInvariant(text2[0]) + text2.Substring(1); + } + + public static string ToLowerCase(this string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + return text.ToLowerInvariant().Replace("_", ""); + } + +} diff --git a/src/Scaffolding/Utilities/SwaggerUtilities.cs b/src/Scaffolding/Utilities/SwaggerUtilities.cs new file mode 100644 index 0000000..cac4a18 --- /dev/null +++ b/src/Scaffolding/Utilities/SwaggerUtilities.cs @@ -0,0 +1,93 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Scaffolding.Utilities; + +internal class SwaggerUtilities +{ + public static class SwaggerEnum + { + public static List Enums; + + public static void Apply(OpenApiSchema schema, SchemaFilterContext context, string jsonSerializerCase) + { + if (schema.Enum?.Count > 0) + { + IList results = new List(); + var enumValues = Enum.GetValues(context.Type); + foreach (var enumValue in enumValues) + { + var enumValueString = enumValue.ToString().ToCase(jsonSerializerCase); + if (Enums?.Contains(enumValueString) == true) + { + continue; + } + + results.Add(new OpenApiString(enumValueString)); + } + + schema.Type = "string"; + schema.Format = null; + schema.Enum = results; + } + } + } + + [AttributeUsage(AttributeTargets.Property)] + public class SwaggerExcludeAttribute : Attribute + { + } + + public class SwaggerExcludeFilter : ISchemaFilter + { + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + if (schema?.Properties == null || context.Type == null) + return; + + var excludedProperties = context.Type.GetProperties() + .Where(t => t.GetCustomAttributes(false).Any(r => r.GetType() == typeof(SwaggerExcludeAttribute))); + + foreach (var excludedProperty in excludedProperties) + { + if (schema.Properties.ContainsKey(excludedProperty.Name)) + { + schema.Properties.Remove(excludedProperty.Name); + } + } + } + } + + public class SnakeEnumSchemaFilter : ISchemaFilter + { + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + SwaggerEnum.Apply(schema, context, "snakecase"); + } + } + + public class CamelEnumSchemaFilter : ISchemaFilter + { + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + SwaggerEnum.Apply(schema, context, "camelcase"); + } + } + + public class LowerEnumSchemaFilter : ISchemaFilter + { + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + SwaggerEnum.Apply(schema, context, "lowercase"); + } + } + + public class OriginalEnumSchemaFilter : ISchemaFilter + { + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + SwaggerEnum.Apply(schema, context, "original"); + } + } +} diff --git a/src/Scaffolding/appsettings.json b/src/Scaffolding/appsettings.json new file mode 100644 index 0000000..fd56ae9 --- /dev/null +++ b/src/Scaffolding/appsettings.json @@ -0,0 +1,59 @@ +{ + "ApiSettings": { + "Name": "ScaffoldingApi", + "Domain": "Domain", + "JsonSerializer": "SnakeCase", + "Port": 8500, + "EnvironmentVariablesPrefix": "ScaffoldingApi_", + "Version": "v1", + "PathBase": "scaffolding", + "RequestKeyProperty": "RequestKey", + "TimeElapsedProperty": "X-Internal-Time", + "TimezoneHeader": "Timezone", + "TimezoneDefault": "UTC", + "SupportedCultures": [ + "en-US", + "pt-BR" + ] + }, + "HealthcheckSettings": { + "Enabled": true, + "Path": "/healthcheck", + "LogEnabled": true + }, + "LogSettings": { + "DebugEnabled": true, + "TitlePrefix": "[{Application}] ", + "JsonBlacklistRequest": [], + "JsonBlacklistResponse": [], + "HeaderBlacklist": [], + "QueryStringBlacklist": [], + "IgnoredRoutes": [], + "Console": { + "Enabled": true, + "MinimumLevel": "Verbose" + } + }, + "DocsSettings": { + "Enabled": true, + "Title": "Scaffolding Api", + "AuthorName": "Scaffolding Team", + "AuthorEmail": "scaffolding@scaffolding.com", + "IgnoredEnums": [], + "PathToReadme": "DOCS.md" + }, + "RateLimitingSettings": { + // Instructions: https://github.com/stefanprodan/AspNetCoreRateLimit + "Enabled": false, + "Storage": "Memory", // Distributed or Memory + "IpRateLimiting": {}, + "IpRateLimitingPolicies": {}, + "ClientRateLimiting": {}, + "ClientRateLimitingPolicies": {} + }, + "ShutdownSettings": { + "ShutdownTimeoutInSeconds": 50, + "Enabled": true, + "Redirect": false + } +}