Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ This package currently supports:
- **OpenTelemetry**: Metrics, traces, and logs with Prometheus support.
- **Health Checks**: Startup validation and endpoints for monitoring.
- **ValidationHelper**: A collection of regex-based validators for common data formats.
- **Maintenance Mode**: Global switch with three modes (`Disabled`, `EnabledForClients`, `EnabledForAll`); clients = all
routes except `/api/admin/*`.
- Various **Extensions and Utilities**, including enumerable, string, dictionary and queryable extensions.

## Prerequisites
Expand Down Expand Up @@ -144,6 +146,7 @@ builder
o.RedisConnectionString = "redis://localhost:6379";
o.ChannelPrefix = "app_name:";
})
.AddMaintenanceMode() // Works only with DistributedCache
.AddDistributedSignalR("redis://localhost:6379","app_name:") // or .AddSignalR()
.AddCors()
.AddHealthChecks();
Expand All @@ -153,6 +156,7 @@ var app = builder.Build();

app
.UseRequestLogging()
.UseMaintenanceMode() //(place early)
.UseResponseCrafter()
.UseCors()
.MapMinimalApis()
Expand Down Expand Up @@ -633,6 +637,17 @@ Integrate OpenTelemetry for observability, including metrics, traces, and loggin
- OTLP exporter
- EF Core telemetry

## Maintenance Mode

- **Modes**
- `Disabled`: normal operation.
- `EnabledForClients`: only `/api/admin/*` or `/hub/admin/*` allowed
- `EnabledForAll`: everything blocked except `/above-board/*` and `OPTIONS`.
- **Security**: use your own auth (recommended). If you don’t have auth yet, you can pass a shared secret to
`MapMaintenanceEndpoint(basePath, querySecret)`.

> Currently, this feature requires a Pandatech.DistributedCache to work correctly.

## HealthChecks

- **Startup Validation:** `app.EnsureHealthy()` performs a health check at startup and terminates the application if it
Expand Down Expand Up @@ -730,6 +745,7 @@ This package includes various extensions and utilities to aid development:
retrieves DefaultTimeZone from `appsettings.json` and sets it as the default time zone.
- **UrlBuilder:** A utility for building URLs with query parameters.
- **Language ISO Code Helper:** Validate, query, and retrieve information about ISO language codes.
- **PhoneUtil class** Utility class for phone number formatting.

### Related NuGet Packages

Expand Down
4 changes: 3 additions & 1 deletion SharedKernel.Demo/Context/InMemoryContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ public class InMemoryContext(DbContextOptions<InMemoryContext> options) : DbCont
{
public DbSet<OutboxMessage> OutboxMessages { get; set; }

protected override void OnModelCreating(ModelBuilder b) =>
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<OutboxMessage>()
.ToTable("outbox_messages")
.HasKey(x => x.Id);
}
}

public class OutboxMessage
Expand Down
27 changes: 18 additions & 9 deletions SharedKernel.Demo/LoggingTestEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text;
using System.Text.Json;
using FluentMinimalApiMapper;
using Microsoft.AspNetCore.Mvc;

Expand Down Expand Up @@ -83,7 +84,7 @@ public void AddRoutes(IEndpointRouteBuilder app)
});

grp.MapGet("/no-content-type",
async (HttpContext ctx) =>
async ctx =>
{
await ctx.Response.Body.WriteAsync(Encoding.UTF8.GetBytes("raw body with no content-type"));
});
Expand All @@ -104,23 +105,27 @@ public void AddRoutes(IEndpointRouteBuilder app)
() =>
{
var sb = new StringBuilder();
for (var i = 0; i < 20_000; i++) sb.Append('x');
for (var i = 0; i < 20_000; i++)
{
sb.Append('x');
}

return Results.Text(sb.ToString(), "text/plain", Encoding.UTF8);
});

grp.MapPost("/echo-with-headers",
([FromBody] SharedKernel.Demo.TestTypes payload, HttpResponse res) =>
([FromBody] TestTypes payload, HttpResponse res) =>
{
res.Headers["Custom-Header-Response"] = "CustomValue";
res.ContentType = "application/json; charset=utf-8";
return Results.Json(payload);
});

grp.MapGet("/ping", () => Results.Text("pong", "text/plain"));


grp.MapGet("/invalid-json",
async (HttpContext ctx) =>
async ctx =>
{
ctx.Response.StatusCode = StatusCodes.Status200OK;
ctx.Response.ContentType = "application/json";
Expand All @@ -134,27 +139,31 @@ public void AddRoutes(IEndpointRouteBuilder app)
var httpClient = httpClientFactory.CreateClient("RandomApiClient");
httpClient.DefaultRequestHeaders.Add("auth", "hardcoded-auth-value");

var body = new SharedKernel.Demo.TestTypes
var body = new TestTypes
{
AnimalType = AnimalType.Cat,
JustText = "Hello from Get Data",
JustNumber = 100
};

var content = new StringContent(System.Text.Json.JsonSerializer.Serialize(body),
System.Text.Encoding.UTF8,
var content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8,
"application/json");

var response = await httpClient.PostAsync("tests/echo-with-headers?barev=5", content);

if (!response.IsSuccessStatusCode)
{
throw new Exception("Something went wrong");
}

var responseBody = await response.Content.ReadAsStringAsync();
var testTypes = System.Text.Json.JsonSerializer.Deserialize<SharedKernel.Demo.TestTypes>(responseBody);
var testTypes = JsonSerializer.Deserialize<TestTypes>(responseBody);

if (testTypes == null)
{
throw new Exception("Failed to get data from external API");
}

return TypedResults.Ok(testTypes);
});
Expand Down
17 changes: 17 additions & 0 deletions SharedKernel.Demo/MaintenanceTestEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using FluentMinimalApiMapper;

namespace SharedKernel.Demo;

public class MaintenanceTestEndpoints : IEndpoint
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var grp = app.MapGroup("/")
.WithTags("maintenance");


grp.MapGet("/api/admin/v1/test", () => Results.Ok("ok"));
grp.MapGet("/api/admin/v2/test", () => Results.Ok("ok"));
grp.MapGet("/api/integration/v1/test", () => Results.Ok("ok"));
}
}
2 changes: 1 addition & 1 deletion SharedKernel.Demo/MassTransitExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public static WebApplicationBuilder AddRmqHealthCheck(this WebApplicationBuilder
var rmqConnectionString = builder.Configuration.GetConnectionString("RabbitMq")!;
var factory = new ConnectionFactory
{
Uri = new Uri(rmqConnectionString),
Uri = new Uri(rmqConnectionString)
};
return factory.CreateConnectionAsync()
.GetAwaiter()
Expand Down
2 changes: 1 addition & 1 deletion SharedKernel.Demo/MessageHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ public async Task SendMessage(SendMessageRequest message)

public class SendMessageRequest : IHubArgument
{
public required string InvocationId { get; set; }
public required string Message { get; set; }
public required string InvocationId { get; set; }
}
11 changes: 7 additions & 4 deletions SharedKernel.Demo/Program.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
using DistributedCache.Extensions;
using FluentMinimalApiMapper;
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ResponseCrafter.Enums;
Expand All @@ -12,6 +10,7 @@
using SharedKernel.Helpers;
using SharedKernel.Logging;
using SharedKernel.Logging.Middleware;
using SharedKernel.Maintenance;
using SharedKernel.OpenApi;
using SharedKernel.Resilience;
using SharedKernel.ValidatorAndMediatR;
Expand All @@ -26,16 +25,17 @@
.AddSerilog(LogBackend.ElasticSearch)
.AddResponseCrafter(NamingConvention.ToSnakeCase)
.AddOpenApi()
.AddMaintenanceMode()
.AddOpenTelemetry()
.AddMinimalApis(AssemblyRegistry.ToArray())
.AddControllers(AssemblyRegistry.ToArray())
.AddMediatrWithBehaviors(AssemblyRegistry.ToArray())
.AddResilienceDefaultPipeline()
.AddDistributedSignalR("localhost:6379", "app_name:") // or .AddSignalR()
.AddDistributedSignalR("localhost:6379", "app_name") // or .AddSignalR()
.AddDistributedCache(o =>
{
o.RedisConnectionString = "localhost:6379";
o.ChannelPrefix = "app_name:";
o.ChannelPrefix = "app_name";
})
.AddMassTransit(AssemblyRegistry.ToArray())
.MapDefaultTimeZone()
Expand Down Expand Up @@ -66,6 +66,7 @@

app
.UseRequestLogging()
.UseMaintenanceMode()
.UseResponseCrafter()
.UseCors()
.MapMinimalApis()
Expand All @@ -78,6 +79,8 @@

app.CreateInMemoryDb();

app.MapMaintenanceEndpoint();


app.MapGet("/outbox-count",
async (InMemoryContext db) =>
Expand Down
22 changes: 11 additions & 11 deletions SharedKernel.Demo/SharedKernel.Demo.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,23 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.Rabbitmq" Version="9.0.0" />
<PackageReference Include="MassTransit.RabbitMQ" Version="8.5.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" />
<PackageReference Include="AspNetCore.HealthChecks.Rabbitmq" Version="9.0.0"/>
<PackageReference Include="MassTransit.RabbitMQ" Version="8.5.2"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8"/>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\src\SharedKernel\SharedKernel.csproj" />
<ProjectReference Include="..\src\SharedKernel\SharedKernel.csproj"/>
</ItemGroup>

<ItemGroup>
<Content Update="appsettings.json">
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
<Content Update="appsettings.Development.json">
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
<Content Update="appsettings.json">
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
<Content Update="appsettings.Development.json">
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>

</Project>
3 changes: 2 additions & 1 deletion src/SharedKernel/Extensions/ControllerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ public static class ControllerExtensions
{
public static WebApplicationBuilder AddControllers(this WebApplicationBuilder builder, Assembly[] assemblies)
{
var mvcBuilder = builder.Services.AddControllers(options => options.Conventions.Add(new ToLowerNamingConvention()));
var mvcBuilder =
builder.Services.AddControllers(options => options.Conventions.Add(new ToLowerNamingConvention()));
foreach (var assembly in assemblies)
{
mvcBuilder.AddApplicationPart(assembly);
Expand Down
3 changes: 2 additions & 1 deletion src/SharedKernel/Extensions/FusionCacheExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,5 @@
// AddBaseFusionCache(builder, instanceName);
// return builder;
// }
// }
// }

2 changes: 1 addition & 1 deletion src/SharedKernel/Extensions/HttpContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static void MarkAsPrivateEndpoint(this IHttpContextAccessor contextAccess
{
contextAccessor.HttpContext?.Response.Headers.Append("X-Private-Endpoint", "1");
}

public static void MarkAsPrivateEndpoint(this HttpContextAccessor contextAccessor)
{
contextAccessor.HttpContext?.Response.Headers.Append("X-Private-Endpoint", "1");
Expand Down
4 changes: 2 additions & 2 deletions src/SharedKernel/Extensions/OpenTelemetryExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ public static WebApplicationBuilder AddOpenTelemetry(this WebApplicationBuilder
.WithMetrics(metrics =>
{
metrics.AddRuntimeInstrumentation()
// .AddFusionCacheInstrumentation()
// .AddFusionCacheInstrumentation()
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddPrometheusExporter();
})
.WithTracing(tracing =>
{
tracing
// .AddFusionCacheInstrumentation()
// .AddFusionCacheInstrumentation()
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation();
Expand Down
1 change: 0 additions & 1 deletion src/SharedKernel/Extensions/SignalRExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using ResponseCrafter.ExceptionHandlers.SignalR;
using Serilog;
using Serilog.Events;
using SharedKernel.Logging;
using SharedKernel.Logging.Middleware;
using StackExchange.Redis;

Expand Down
21 changes: 12 additions & 9 deletions src/SharedKernel/Helpers/MethodTimingStatistics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,27 @@
namespace SharedKernel.Helpers;

/// <summary>
/// Tracks and logs timing statistics of method executions for benchmarking.
/// Tracks and logs timing statistics of method executions for benchmarking.
/// </summary>
/// <remarks>
/// Not recommended for production usage. Use only for performance diagnostics or quick benchmarks.
/// Not recommended for production usage. Use only for performance diagnostics or quick benchmarks.
/// </remarks>
public class MethodTimingStatistics
{
private static readonly List<MethodTimingStatistics> CollectedStats = [];
public required string MethodName { get; init; }
private int InvocationCount { get; set; }
private double TotalElapsedMilliseconds { get; set; }
private double AverageElapsedMilliseconds { get; set; }

private static readonly List<MethodTimingStatistics> CollectedStats = [];

/// <summary>
/// Updates or adds statistics for a particular method based on the start timestamp.
/// Updates or adds statistics for a particular method based on the start timestamp.
/// </summary>
/// <param name="methodName">The name of the method being tracked.</param>
/// <param name="startTimestamp">A timestamp (via <see cref="Stopwatch.GetTimestamp"/>) captured before the method execution.</param>
/// <param name="startTimestamp">
/// A timestamp (via <see cref="Stopwatch.GetTimestamp" />) captured before the method
/// execution.
/// </param>
public static void RecordExecution(string methodName, long startTimestamp)
{
var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp)
Expand All @@ -51,9 +53,9 @@ public static void RecordExecution(string methodName, long startTimestamp)
}

/// <summary>
/// Logs the accumulated statistics for all tracked methods.
/// Logs the accumulated statistics for all tracked methods.
/// </summary>
/// <param name="logger">An <see cref="ILogger"/> instance used for logging the statistics.</param>
/// <param name="logger">An <see cref="ILogger" /> instance used for logging the statistics.</param>
public static void LogAll(ILogger logger)
{
foreach (var stat in CollectedStats)
Expand All @@ -71,7 +73,7 @@ public static void LogAll(ILogger logger)
}

/// <summary>
/// Clears the statistics for a specific method or for all methods if none is specified.
/// Clears the statistics for a specific method or for all methods if none is specified.
/// </summary>
/// <param name="methodName">Optional method name to clear; if null or empty, clears all statistics.</param>
public static void ClearStatistics(string? methodName = null)
Expand All @@ -85,6 +87,7 @@ public static void ClearStatistics(string? methodName = null)
CollectedStats.Clear();
}
}

private static string FormatDuration(double milliseconds)
{
switch (milliseconds)
Expand Down
Loading