From cc1063bdcc7d16be38b0d70a0c89c64d272108db Mon Sep 17 00:00:00 2001 From: Haik Date: Wed, 31 Dec 2025 10:51:52 +0400 Subject: [PATCH 1/2] swagger ui work in progress --- .../OpenApi/UiAssets/panda-style.css | 314 ++++++++++-------- .../OpenApi/UiAssets/panda-style.js | 14 - 2 files changed, 184 insertions(+), 144 deletions(-) diff --git a/src/SharedKernel/OpenApi/UiAssets/panda-style.css b/src/SharedKernel/OpenApi/UiAssets/panda-style.css index bac3c9a..872367b 100644 --- a/src/SharedKernel/OpenApi/UiAssets/panda-style.css +++ b/src/SharedKernel/OpenApi/UiAssets/panda-style.css @@ -1,157 +1,211 @@ -.swagger-ui .auth-container .btn.modal-btn.auth { - display: flex; - justify-content: center; +/* Glass topbar (light + dark via CSS vars) */ +:root { + --sw-topbar-bg: rgba(255, 255, 255, 0.65); + --sw-topbar-border: rgba(0, 0, 0, 0.10); + --sw-topbar-shadow: 0 6px 24px rgba(0, 0, 0, 0.10); + --sw-topbar-blur: 10px; } -.topbar-wrapper > a > svg { - opacity: 0; +html.dark-mode { + --sw-topbar-bg: rgba(18, 18, 18, 0.55); + --sw-topbar-border: rgba(255, 255, 255, 0.14); + --sw-topbar-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); } -.swagger-ui .topbar .download-url-wrapper { - display: flex; -} - -.swagger-ui .topbar .download-url-wrapper .select-label select { - border-color: #4E37D3; -} - -.swagger-ui .topbar .download-url-wrapper .select-label span { - color: transparent; - user-select: none; -} - - -.swagger-ui .topbar-wrapper img { - visibility: hidden; -} - -element.style { -} - -.swagger-ui .auth-btn-wrapper { - gap: 20px; - margin-top: 20px; -} - -.swagger-ui .auth-wrapper .authorize { - margin: 0; -} - -.swagger-ui .btn.authorize.locked, .swagger-ui .btn.authorize.unlocked { - display: flex; - align-items: center; - justify-content: center; - gap: 7px - -} - -.swagger-ui .btn.authorize span { - clear: both; - padding: 0; - margin-top: 3px; -} - -.swagger-ui .auth-container .btn.modal-btn.auth { - min-width: 250px; - padding: 10px 0; -} - -.swagger-ui .topbar-wrapper .link::before { - content: ""; - background: url("/swagger-resources/logo.svg") no-repeat center; - background-size: contain; - width: 36.402px; - height: 28.8px; - display: inline-block; - position: relative; -} +.swagger-ui .topbar { + background: var(--sw-topbar-bg) !important; + backdrop-filter: blur(var(--sw-topbar-blur)) saturate(1.2); + -webkit-backdrop-filter: blur(var(--sw-topbar-blur)) saturate(1.2); + border-bottom: 1px solid var(--sw-topbar-border); + box-shadow: var(--sw-topbar-shadow); -/* Add your second image */ -.swagger-ui .topbar-wrapper .link::after { - content: ""; - background: url("/swagger-resources/logo-wording.svg") no-repeat center; - background-size: contain; - width: 90.0276px; - height: 20.2044px; - display: inline-block; - position: relative; - left: -132px; -} - -div.topbar { - background: transparent !important; - backdrop-filter: blur(3px); - position: fixed; - width: 100%; + position: sticky; top: 0; - border-bottom: 1px solid #ffffff; z-index: 1000; } -.swagger-ui { - margin-top: 128px; -} - -.swagger-ui .btn.authorize.unlocked { - background-color: white !important; - color: black !important; - border: 2px solid #4E37D3 !important; -} - -.swagger-ui .btn.authorize.unlocked svg { - fill: black !important; -} - -.swagger-ui .btn.authorize.locked { - background-color: #4E37D3 !important; - color: #ffffff !important; - border: 2px solid #4E37D3 !important; -} - -.swagger-ui .btn.authorize.locked svg { - fill: #ffffff !important; -} - -.swagger-ui .auth-container .btn.modal-btn.auth { - background-color: #4E37D3 !important; - color: #ffffff !important; - border: 1px solid #4E37D3 !important; - width: 100%; +/*.swagger-ui .auth-container .btn.modal-btn.auth {*/ +/* display: flex;*/ +/* justify-content: center;*/ +/*}*/ + +/*.topbar-wrapper > a > svg {*/ +/* opacity: 0;*/ +/*}*/ + + +/*.swagger-ui .topbar .download-url-wrapper .select-label select {*/ +/* border-color: #4E37D3;*/ +/*}*/ + +/*.swagger-ui .topbar .download-url-wrapper .select-label span {*/ +/* color: transparent;*/ +/* user-select: none;*/ +/*}*/ + + +/*.swagger-ui .topbar-wrapper img {*/ +/* visibility: hidden;*/ +/*}*/ + +/*element.style {*/ +/*}*/ + +/*.swagger-ui .auth-btn-wrapper {*/ +/* gap: 20px;*/ +/* margin-top: 20px;*/ +/*}*/ + +/*.swagger-ui .auth-wrapper .authorize {*/ +/* margin: 0;*/ +/*}*/ + +/*.swagger-ui .btn.authorize.locked, .swagger-ui .btn.authorize.unlocked {*/ +/* display: flex;*/ +/* align-items: center;*/ +/* justify-content: center;*/ +/* gap: 7px*/ + +/*}*/ + +/*.swagger-ui .btn.authorize span {*/ +/* clear: both;*/ +/* padding: 0;*/ +/* margin-top: 3px;*/ +/*}*/ + +/*.swagger-ui .auth-container .btn.modal-btn.auth {*/ +/* min-width: 250px;*/ +/* padding: 10px 0;*/ +/*}*/ + +/*.swagger-ui .topbar-wrapper .link::before {*/ +/* content: "";*/ +/* background: url("/swagger-resources/logo.svg") no-repeat center;*/ +/* background-size: contain;*/ +/* width: 36.402px;*/ +/* height: 28.8px;*/ +/* display: inline-block;*/ +/* position: relative;*/ +/*}*/ + +/*!* Add your second image *!*/ +/*.swagger-ui .topbar-wrapper .link::after {*/ +/* content: "";*/ +/* background: url("/swagger-resources/logo-wording.svg") no-repeat center;*/ +/* background-size: contain;*/ +/* width: 90.0276px;*/ +/* height: 20.2044px;*/ +/* display: inline-block;*/ +/* position: relative;*/ +/* left: -132px;*/ +/*}*/ + +/*div.topbar {*/ +/* background: transparent !important;*/ +/* backdrop-filter: blur(3px);*/ +/* position: fixed;*/ +/* width: 100%;*/ +/* top: 0;*/ +/* border-bottom: 1px solid #ffffff;*/ +/* z-index: 1000;*/ +/*}*/ + +/*.swagger-ui {*/ +/* margin-top: 128px;*/ +/*}*/ + +/*.swagger-ui .btn.authorize.unlocked {*/ +/* background-color: white !important;*/ +/* color: black !important;*/ +/* border: 2px solid #4E37D3 !important;*/ +/*}*/ + +/*.swagger-ui .btn.authorize.unlocked svg {*/ +/* fill: black !important;*/ +/*}*/ + +/*.swagger-ui .btn.authorize.locked {*/ +/* background-color: #4E37D3 !important;*/ +/* color: #ffffff !important;*/ +/* border: 2px solid #4E37D3 !important;*/ +/*}*/ + +/*.swagger-ui .btn.authorize.locked svg {*/ +/* fill: #ffffff !important;*/ +/*}*/ + +/*.swagger-ui .auth-container .btn.modal-btn.auth {*/ +/* background-color: #4E37D3 !important;*/ +/* color: #ffffff !important;*/ +/* border: 1px solid #4E37D3 !important;*/ +/* width: 100%;*/ +/*}*/ + +/*.btn.modal-btn.auth.btn-done.button {*/ +/* display: none;*/ +/*}*/ + +/*.auth-container {*/ +/* padding: 0 !important;*/ +/*}*/ + +/*.swagger-ui .dialog-ux .modal-ux {*/ +/* max-width: 600px;*/ +/*}*/ + +/*.opblock-summary-get:hover {*/ +/* background-color: #b3e0ff !important;*/ +/*}*/ + +/*.swagger-ui .auth-container input[type=text] {*/ +/* width: 100%;*/ +/*}*/ + + +/* colored hover effects for different HTTP methods */ + +.swagger-ui .opblock-summary-get:hover { + background-color: #9dc9ff !important; +} + +.swagger-ui .opblock-summary-post:hover { + background-color: #83e697 !important; } -.btn.modal-btn.auth.btn-done.button { - display: none; +.swagger-ui .opblock-summary-put:hover { + background-color: #f0cc9f !important; } -.auth-container { - padding: 0 !important; +.swagger-ui .opblock-summary-patch:hover { + background-color: #a2eede !important; } -.swagger-ui .dialog-ux .modal-ux { - max-width: 600px; +.swagger-ui .opblock-summary-delete:hover { + background-color: #faa3a3 !important; } -.opblock-summary-get:hover { - background-color: #b3e0ff !important; +/* dark mode (Swagger UI 5.31+): softer hover tints */ +html.dark-mode { + --opblock-hover-alpha: .22; } -.swagger-ui .auth-container input[type=text] { - - width: 100%; +html.dark-mode .swagger-ui .opblock-summary-get:hover { + background-color: rgb(157 201 255 / var(--opblock-hover-alpha)) !important; } -.opblock-summary-post:hover { - background-color: #83e697 !important; +html.dark-mode .swagger-ui .opblock-summary-post:hover { + background-color: rgb(131 230 151 / var(--opblock-hover-alpha)) !important; } -.opblock-summary-put:hover { - background-color: #f0cc9f !important; +html.dark-mode .swagger-ui .opblock-summary-put:hover { + background-color: rgb(240 204 159 / var(--opblock-hover-alpha)) !important; } -.opblock-summary-patch:hover { - background-color: #a2eede !important; +html.dark-mode .swagger-ui .opblock-summary-patch:hover { + background-color: rgb(162 238 222 / var(--opblock-hover-alpha)) !important; } -.opblock-summary-delete:hover { - background-color: #faa3a3 !important; -} +html.dark-mode .swagger-ui .opblock-summary-delete:hover { + background-color: rgb(250 163 163 / var(--opblock-hover-alpha)) !important; +} \ No newline at end of file diff --git a/src/SharedKernel/OpenApi/UiAssets/panda-style.js b/src/SharedKernel/OpenApi/UiAssets/panda-style.js index 4ac6858..9a35c99 100644 --- a/src/SharedKernel/OpenApi/UiAssets/panda-style.js +++ b/src/SharedKernel/OpenApi/UiAssets/panda-style.js @@ -12,18 +12,4 @@ document.addEventListener('DOMContentLoaded', function () { newLink.href = faviconPath; document.head.appendChild(newLink); } - - // Scroll modal to top when it appears - const observer = new MutationObserver(() => { - const modal = document.querySelector('.modal-ux-content'); - if (modal) { - modal.scrollTop = 0; - observer.disconnect(); - } - }); - - observer.observe(document.body, { - childList: true, - subtree: true, - }); }); From 029fa468c0b2c8ae53343617d3b410e8ed634dd2 Mon Sep 17 00:00:00 2001 From: Haik Date: Tue, 27 Jan 2026 13:19:18 +0400 Subject: [PATCH 2/2] nuget updates, request/response enhancement, perf boosts --- SharedKernel.Demo/LoggingTestEndpoints.cs | 336 ++++++++++------ SharedKernel.Demo/MessageHub.cs | 1 - SharedKernel.Demo/SharedKernel.Demo.csproj | 6 +- .../Extensions/DictionaryExtensions.cs | 37 +- .../Extensions/HostEnvironmentExtensions.cs | 81 ++-- .../Extensions/SignalRExtensions.cs | 56 +-- .../JsonConverters/EnumConverterFactory.cs | 11 +- .../Middleware/CappedResponseBodyStream.cs | 95 ++--- .../Logging/Middleware/HttpLogHelper.cs | 315 +++++++-------- .../Logging/Middleware/LogFormatting.cs | 34 +- .../Logging/Middleware/LoggingExtensions.cs | 6 - .../Logging/Middleware/LoggingOptions.cs | 58 ++- .../Logging/Middleware/MediaTypeUtil.cs | 31 +- .../Middleware/OutboundLoggingHandler.cs | 253 +++++------- .../Logging/Middleware/ReductionHelper.cs | 370 +++++++++--------- .../Middleware/RequestLoggingMiddleware.cs | 225 ++++++----- .../Middleware/SignalRLoggingHubFilter.cs | 161 ++++---- .../Maintenance/MaintenanceMiddleware.cs | 5 +- .../OpenApi/UiAssets/panda-style.css | 228 ++++------- .../OpenApi/UiAssets/panda-style.js | 23 +- src/SharedKernel/OpenApi/UiExtensions.cs | 71 ++-- src/SharedKernel/SharedKernel.csproj | 56 +-- .../ValidationBehaviorWithResponse.cs | 1 - .../ValidationBehaviorWithoutResponse.cs | 1 - 24 files changed, 1237 insertions(+), 1224 deletions(-) diff --git a/SharedKernel.Demo/LoggingTestEndpoints.cs b/SharedKernel.Demo/LoggingTestEndpoints.cs index 04ff788..dff191f 100644 --- a/SharedKernel.Demo/LoggingTestEndpoints.cs +++ b/SharedKernel.Demo/LoggingTestEndpoints.cs @@ -10,166 +10,268 @@ public class LoggingTestEndpoints : IEndpoint public void AddRoutes(IEndpointRouteBuilder app) { var grp = app.MapGroup("/tests") - .WithTags("logs"); + .WithTags("Logging Tests"); - grp.MapPost("/json", ([FromBody] TestTypes payload) => Results.Ok(payload)); + // ═══════════════════════════════════════════════════════════════════ + // INBOUND REQUEST TESTS + // ═══════════════════════════════════════════════════════════════════ - grp.MapPost("/json-array", - ([FromBody] int[] numbers) => Results.Ok(new - { - count = numbers?.Length ?? 0, - numbers - })); + grp.MapPost("/json", ([FromBody] TestTypes payload) => Results.Ok(payload)) + .WithSummary("JSON body - should log request and response bodies"); + + grp.MapPost("/json-array", ([FromBody] int[] numbers) => Results.Ok(new { count = numbers.Length, numbers })) + .WithSummary("JSON array body"); grp.MapPost("/form-urlencoded", ([FromForm] FormUrlDto form) => Results.Ok(form)) - .DisableAntiforgery(); + .DisableAntiforgery() + .WithSummary("Form URL encoded - should log form fields"); - grp.MapGet("/json-per-property", - () => + grp.MapPost("/multipart", async ([FromForm] MultipartDto form, CancellationToken ct) => { - var big = new string('x', 6_000); // ~6KB - var payload = new + var meta = new { - small = "ok", - bigString = big, // should be redacted/omitted per-property - tail = "done" + form.Description, + File = form.File is null ? null : new + { + form.File.FileName, + form.File.ContentType, + form.File.Length + } }; - return Results.Json(payload); - }); + return Results.Ok(meta); + }) + .DisableAntiforgery() + .WithSummary("Multipart form - files should show [OMITTED: file XKB]"); + grp.MapGet("/query", ([AsParameters] QueryDto q) => Results.Ok(q)) + .WithSummary("Query string parameters - should appear in Query scope field"); - grp.MapPost("/multipart", - async ([FromForm] MultipartDto form) => - { - var meta = new - { - form.Description, - File = form.File is null - ? null - : new - { - form.File.FileName, - form.File.ContentType, - form.File.Length - } - }; - return Results.Ok(meta); - }) - .DisableAntiforgery(); - - grp.MapGet("/query", ([AsParameters] QueryDto q) => Results.Ok(q)); - - grp.MapGet("/route/{id:int}", - (int id) => Results.Ok(new - { - id - })); - - grp.MapPost("/headers", - ([FromHeader(Name = "x-trace-id")] string? traceId, HttpRequest req) => - { - var hasAuth = req.Headers.ContainsKey("Authorization"); - return Results.Ok(new - { - traceId, - hasAuth - }); - }); + grp.MapGet("/route/{id:int}", (int id) => Results.Ok(new { id })) + .WithSummary("Route parameter test"); - grp.MapGet("/binary", - () => + // ═══════════════════════════════════════════════════════════════════ + // RESPONSE SIZE/TYPE TESTS + // ═══════════════════════════════════════════════════════════════════ + + grp.MapGet("/large-json", () => + { + var items = Enumerable.Range(1, 10_000).Select(i => new { i, text = "xxxxxxxxxx" }); + return Results.Ok(items); + }) + .WithSummary("Large JSON response - should show [OMITTED: exceeds-limit]"); + + grp.MapGet("/large-text", () => + { + var text = new string('x', 20_000); + return Results.Text(text, "text/plain", Encoding.UTF8); + }) + .WithSummary("Large text response - should show [OMITTED: exceeds-limit]"); + + grp.MapGet("/binary", () => { var bytes = Encoding.UTF8.GetBytes("Hello Binary"); return Results.File(bytes, "application/octet-stream", "demo.bin"); - }); + }) + .WithSummary("Binary response - should show [OMITTED: non-text]"); - grp.MapGet("/no-content-type", - async ctx => + grp.MapGet("/no-content-type", async ctx => { - await ctx.Response.Body.WriteAsync(Encoding.UTF8.GetBytes("raw body with no content-type")); - }); + await ctx.Response.Body.WriteAsync("raw body with no content-type"u8.ToArray()); + }) + .WithSummary("No content-type header - body logging behavior"); - grp.MapGet("/large-json", - () => + grp.MapGet("/invalid-json", async ctx => { - var items = Enumerable.Range(1, 10_000) - .Select(i => new - { - i, - text = "xxxxxxxxxx" - }); - return Results.Ok(items); - }); + ctx.Response.StatusCode = StatusCodes.Status200OK; + ctx.Response.ContentType = "application/json"; + await ctx.Response.Body.WriteAsync("{ invalid-json: true"u8.ToArray()); + }) + .WithSummary("Invalid JSON - should show invalidJson: true in logs"); - grp.MapGet("/large-text", - () => + grp.MapGet("/json-per-property", () => { - var sb = new StringBuilder(); - for (var i = 0; i < 20_000; i++) - { - sb.Append('x'); - } + var big = new string('x', 6_000); + return Results.Json(new { small = "ok", bigString = big, tail = "done" }); + }) + .WithSummary("Large property value - should show [OMITTED: exceeds-limit ~XKB]"); - return Results.Text(sb.ToString(), "text/plain", Encoding.UTF8); - }); + grp.MapGet("/ping", () => Results.Text("pong", "text/plain")) + .WithSummary("Simple ping - minimal logging"); - grp.MapPost("/echo-with-headers", - ([FromBody] TestTypes payload, HttpResponse res) => + grp.MapGet("/empty", () => Results.NoContent()) + .WithSummary("204 No Content - empty response body"); + + // ═══════════════════════════════════════════════════════════════════ + // HEADER REDACTION TESTS + // ═══════════════════════════════════════════════════════════════════ + + grp.MapPost("/headers", ([FromHeader(Name = "X-Trace-Id")] string? traceId, + [FromHeader(Name = "Authorization")] string? auth, + [FromHeader(Name = "X-Api-Token")] string? token, + HttpRequest req) => { - res.Headers["Custom-Header-Response"] = "CustomValue"; + return Results.Ok(new + { + traceId, + hasAuth = !string.IsNullOrEmpty(auth), + hasToken = !string.IsNullOrEmpty(token), + cookieCount = req.Cookies.Count + }); + }) + .WithSummary("Header redaction - Authorization, Token, Cookie should be [REDACTED]"); + + grp.MapPost("/echo-with-headers", ([FromBody] TestTypes payload, HttpResponse res) => + { + res.Headers["X-Custom-Response"] = "CustomValue"; + res.Headers["X-Auth-Token"] = "secret-token-value"; res.ContentType = "application/json; charset=utf-8"; return Results.Json(payload); - }); + }) + .WithSummary("Response headers - X-Auth-Token should be [REDACTED]"); + + // ═══════════════════════════════════════════════════════════════════ + // SENSITIVE DATA REDACTION TESTS + // ═══════════════════════════════════════════════════════════════════ - grp.MapGet("/ping", () => Results.Text("pong", "text/plain")); + grp.MapPost("/login", ([FromBody] LoginDto login) => Results.Ok(new { success = true, user = login.Username })) + .WithSummary("Login - password field should be [REDACTED]"); + grp.MapPost("/payment", ([FromBody] PaymentDto payment) => + Results.Ok(new { success = true, last4 = payment.Pan?[^4..] })) + .WithSummary("Payment - pan, cvv fields should be [REDACTED]"); - grp.MapGet("/invalid-json", - async ctx => + // ═══════════════════════════════════════════════════════════════════ + // OUTBOUND HTTP CLIENT TESTS (tests OutboundLoggingHandler) + // ═══════════════════════════════════════════════════════════════════ + + grp.MapGet("/outbound/json", async (IHttpClientFactory factory) => { - ctx.Response.StatusCode = StatusCodes.Status200OK; - ctx.Response.ContentType = "application/json"; - await ctx.Response.Body.WriteAsync("{ invalid-json: true"u8.ToArray()); - }); + var client = factory.CreateClient("RandomApiClient"); + var body = new TestTypes { AnimalType = AnimalType.Cat, JustText = "Outbound JSON", JustNumber = 42 }; + var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + var response = await client.PostAsync("tests/echo-with-headers", content); + return Results.Ok(await response.Content.ReadFromJsonAsync()); + }) + .WithSummary("Outbound JSON request - should log request/response bodies"); - // Your HttpClient test moved here, targets our echo endpoint - grp.MapGet("/httpclient", - async (IHttpClientFactory httpClientFactory) => + grp.MapGet("/outbound/form-urlencoded", async (IHttpClientFactory factory) => { - var httpClient = httpClientFactory.CreateClient("RandomApiClient"); - httpClient.DefaultRequestHeaders.Add("auth", "hardcoded-auth-value"); + var client = factory.CreateClient("RandomApiClient"); - var body = new TestTypes + var formContent = new FormUrlEncodedContent(new Dictionary { - AnimalType = AnimalType.Cat, - JustText = "Hello from Get Data", - JustNumber = 100 - }; - - var content = new StringContent(JsonSerializer.Serialize(body), - Encoding.UTF8, - "application/json"); + ["Username"] = "testuser", // Match FormUrlDto fields! + ["Password"] = "secret123", + ["Note"] = "test note" + }); - var response = await httpClient.PostAsync("tests/echo-with-headers?barev=5", content); + var response = await client.PostAsync("tests/form-urlencoded", formContent); + var body = await response.Content.ReadAsStringAsync(); + + return Results.Json(new { + statusCode = (int)response.StatusCode, + body + }); + }) + .WithSummary("Outbound FormUrlEncodedContent"); + + grp.MapGet("/outbound/form-as-string", async (IHttpClientFactory factory) => + { + var client = factory.CreateClient("RandomApiClient"); + + // Use FormUrlEncodedContent instead of StringContent for proper form encoding + var formContent = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "password", + ["username"] = "testuser", + ["password"] = "secret123", + ["scope"] = "openid" + }); + var response = await client.PostAsync("tests/form-urlencoded", formContent); + + // Handle non-success responses gracefully if (!response.IsSuccessStatusCode) { - throw new Exception("Something went wrong"); + var errorBody = await response.Content.ReadAsStringAsync(); + return Results.Json(new { + success = false, + statusCode = (int)response.StatusCode, + error = errorBody + }); } + + return Results.Ok(await response.Content.ReadFromJsonAsync()); + }) + .WithSummary("Outbound form-urlencoded - now using FormUrlEncodedContent for proper binding"); + - var responseBody = await response.Content.ReadAsStringAsync(); - var testTypes = JsonSerializer.Deserialize(responseBody); + grp.MapGet("/outbound/multipart", async (IHttpClientFactory factory) => + { + var client = factory.CreateClient("RandomApiClient"); + + // Create multipart content + var content = new MultipartFormDataContent(); + content.Add(new StringContent("Test description"), "Description"); + content.Add(new ByteArrayContent("fake file content"u8.ToArray()), "File", "test.txt"); - if (testTypes == null) + var response = await client.PostAsync("tests/multipart", content); + + if (!response.IsSuccessStatusCode) { - throw new Exception("Failed to get data from external API"); + var errorBody = await response.Content.ReadAsStringAsync(); + return Results.Json(new { + success = false, + statusCode = (int)response.StatusCode, + error = errorBody + }); } + + return Results.Ok(await response.Content.ReadAsStringAsync()); + }) + .WithSummary("Outbound multipart - should show [field] and [OMITTED: file]"); + + grp.MapGet("/outbound/large", async (IHttpClientFactory factory) => + { + var client = factory.CreateClient("RandomApiClient"); + var response = await client.GetAsync("tests/large-json"); + return Results.Ok(new { statusCode = (int)response.StatusCode }); + }) + .WithSummary("Outbound large response - should show [OMITTED: exceeds-limit]"); + + grp.MapGet("/outbound/binary", async (IHttpClientFactory factory) => + { + var client = factory.CreateClient("RandomApiClient"); + var response = await client.GetAsync("tests/binary"); + return Results.Ok(new { statusCode = (int)response.StatusCode, length = response.Content.Headers.ContentLength }); + }) + .WithSummary("Outbound binary response - should show [OMITTED: non-text]"); - return TypedResults.Ok(testTypes); - }); + // ═══════════════════════════════════════════════════════════════════ + // EDGE CASES + // ═══════════════════════════════════════════════════════════════════ + + grp.MapPost("/chunked", async (HttpRequest req) => + { + using var reader = new StreamReader(req.Body); + var body = await reader.ReadToEndAsync(); + return Results.Ok(new { length = body.Length }); + }) + .WithSummary("Chunked transfer encoding test"); + + grp.MapGet("/slow", async (CancellationToken ct) => + { + await Task.Delay(2000, ct); + return Results.Ok(new { delayed = true }); + }) + .WithSummary("Slow endpoint - check ElapsedMs in logs"); } } +// ═══════════════════════════════════════════════════════════════════ +// DTOs +// ═══════════════════════════════════════════════════════════════════ + public record FormUrlDto(string? Username, string? Password, string? Note); public class MultipartDto @@ -178,4 +280,8 @@ public class MultipartDto public string? Description { get; init; } } -public record QueryDto(int Page = 1, int PageSize = 10, string? Search = null, string[]? Tags = null); \ No newline at end of file +public record QueryDto(int Page = 1, int PageSize = 10, string? Search = null, string[]? Tags = null); + +public record LoginDto(string Username, string Password, bool RememberMe = false); + +public record PaymentDto(string? Pan, string? Cvv, string? CardholderName, decimal Amount); diff --git a/SharedKernel.Demo/MessageHub.cs b/SharedKernel.Demo/MessageHub.cs index 610b0c1..1b7e5e0 100644 --- a/SharedKernel.Demo/MessageHub.cs +++ b/SharedKernel.Demo/MessageHub.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.SignalR; using ResponseCrafter.ExceptionHandlers.SignalR; -using System.Threading; namespace SharedKernel.Demo; diff --git a/SharedKernel.Demo/SharedKernel.Demo.csproj b/SharedKernel.Demo/SharedKernel.Demo.csproj index a135fe9..14a82dc 100644 --- a/SharedKernel.Demo/SharedKernel.Demo.csproj +++ b/SharedKernel.Demo/SharedKernel.Demo.csproj @@ -8,9 +8,9 @@ - - - + + + diff --git a/src/SharedKernel/Extensions/DictionaryExtensions.cs b/src/SharedKernel/Extensions/DictionaryExtensions.cs index 4b0d920..d1c4a52 100644 --- a/src/SharedKernel/Extensions/DictionaryExtensions.cs +++ b/src/SharedKernel/Extensions/DictionaryExtensions.cs @@ -5,30 +5,31 @@ namespace SharedKernel.Extensions; public static class DictionaryExtensions { - public static TValue? GetOrAdd(this Dictionary dict, TKey key, TValue? value) - where TKey : notnull + extension(Dictionary dict) where TKey : notnull { - ref var val = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, key, out var exists); - - if (exists) + public TValue? GetOrAdd(TKey key, TValue? value) { + ref var val = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, key, out var exists); + + if (exists) + { + return val; + } + + val = value; return val; } - val = value; - return val; - } - - public static bool TryUpdate(this Dictionary dict, TKey key, TValue value) - where TKey : notnull - { - ref var val = ref CollectionsMarshal.GetValueRefOrNullRef(dict, key); - if (Unsafe.IsNullRef(ref val)) + public bool TryUpdate(TKey key, TValue value) { - return false; - } + ref var val = ref CollectionsMarshal.GetValueRefOrNullRef(dict, key); + if (Unsafe.IsNullRef(ref val)) + { + return false; + } - val = value; - return true; + val = value; + return true; + } } } \ No newline at end of file diff --git a/src/SharedKernel/Extensions/HostEnvironmentExtensions.cs b/src/SharedKernel/Extensions/HostEnvironmentExtensions.cs index 7b49961..87dd665 100644 --- a/src/SharedKernel/Extensions/HostEnvironmentExtensions.cs +++ b/src/SharedKernel/Extensions/HostEnvironmentExtensions.cs @@ -4,58 +4,61 @@ namespace SharedKernel.Extensions; public static class HostEnvironmentExtensions { - public static bool IsQa(this IHostEnvironment hostEnvironment) + extension(IHostEnvironment hostEnvironment) { - ArgumentNullException.ThrowIfNull(hostEnvironment); - - return hostEnvironment.IsEnvironment("QA"); - } - - public static bool IsLocal(this IHostEnvironment hostEnvironment) - { - ArgumentNullException.ThrowIfNull(hostEnvironment); - - return hostEnvironment.IsEnvironment("Local"); - } - - public static bool IsLocalOrDevelopment(this IHostEnvironment hostEnvironment) - { - ArgumentNullException.ThrowIfNull(hostEnvironment); - - return hostEnvironment.IsLocal() || hostEnvironment.IsDevelopment(); - } - - public static bool IsLocalOrDevelopmentOrQa(this IHostEnvironment hostEnvironment) - { - ArgumentNullException.ThrowIfNull(hostEnvironment); - - return hostEnvironment.IsLocal() || hostEnvironment.IsDevelopment() || hostEnvironment.IsQa(); - } + public bool IsQa() + { + ArgumentNullException.ThrowIfNull(hostEnvironment); - public static string GetShortEnvironmentName(this IHostEnvironment environment) - { - ArgumentNullException.ThrowIfNull(environment); + return hostEnvironment.IsEnvironment("QA"); + } - if (environment.IsLocal()) + public bool IsLocal() { - return "local"; + ArgumentNullException.ThrowIfNull(hostEnvironment); + + return hostEnvironment.IsEnvironment("Local"); } - if (environment.IsDevelopment()) + public bool IsLocalOrDevelopment() { - return "dev"; + ArgumentNullException.ThrowIfNull(hostEnvironment); + + return hostEnvironment.IsLocal() || hostEnvironment.IsDevelopment(); } - if (environment.IsQa()) + public bool IsLocalOrDevelopmentOrQa() { - return "qa"; + ArgumentNullException.ThrowIfNull(hostEnvironment); + + return hostEnvironment.IsLocal() || hostEnvironment.IsDevelopment() || hostEnvironment.IsQa(); } - if (environment.IsStaging()) + public string GetShortEnvironmentName() { - return "staging"; - } + ArgumentNullException.ThrowIfNull(hostEnvironment); + + if (hostEnvironment.IsLocal()) + { + return "local"; + } + + if (hostEnvironment.IsDevelopment()) + { + return "dev"; + } - return ""; + if (hostEnvironment.IsQa()) + { + return "qa"; + } + + if (hostEnvironment.IsStaging()) + { + return "staging"; + } + + return ""; + } } } \ No newline at end of file diff --git a/src/SharedKernel/Extensions/SignalRExtensions.cs b/src/SharedKernel/Extensions/SignalRExtensions.cs index 6d36900..97c6473 100644 --- a/src/SharedKernel/Extensions/SignalRExtensions.cs +++ b/src/SharedKernel/Extensions/SignalRExtensions.cs @@ -11,39 +11,41 @@ namespace SharedKernel.Extensions; public static class SignalRExtensions { - public static WebApplicationBuilder AddSignalR(this WebApplicationBuilder builder) + extension(WebApplicationBuilder builder) { - builder.AddSignalRWithFiltersAndMessagePack(); - return builder; - } + public WebApplicationBuilder AddSignalR() + { + builder.AddSignalRWithFiltersAndMessagePack(); + return builder; + } - public static WebApplicationBuilder AddDistributedSignalR(this WebApplicationBuilder builder, - string redisUrl, - string redisChannelName) - { - builder.AddSignalRWithFiltersAndMessagePack() - .AddStackExchangeRedis(redisUrl, - options => - { - options.Configuration.ChannelPrefix = RedisChannel.Literal(redisChannelName); - }); + public WebApplicationBuilder AddDistributedSignalR(string redisUrl, + string redisChannelName) + { + builder.AddSignalRWithFiltersAndMessagePack() + .AddStackExchangeRedis(redisUrl, + options => + { + options.Configuration.ChannelPrefix = RedisChannel.Literal(redisChannelName); + }); - return builder; - } + return builder; + } - private static ISignalRServerBuilder AddSignalRWithFiltersAndMessagePack(this WebApplicationBuilder builder) - { - return builder.Services - .AddSignalR(o => - { - if (Log.Logger.IsEnabled(LogEventLevel.Information)) + private ISignalRServerBuilder AddSignalRWithFiltersAndMessagePack() + { + return builder.Services + .AddSignalR(o => { - o.AddFilter(); - } + if (Log.Logger.IsEnabled(LogEventLevel.Information)) + { + o.AddFilter(); + } - o.AddFilter(); - }) - .AddMessagePackProtocol(); + o.AddFilter(); + }) + .AddMessagePackProtocol(); + } } } \ No newline at end of file diff --git a/src/SharedKernel/JsonConverters/EnumConverterFactory.cs b/src/SharedKernel/JsonConverters/EnumConverterFactory.cs index 83e5d65..f0dd115 100644 --- a/src/SharedKernel/JsonConverters/EnumConverterFactory.cs +++ b/src/SharedKernel/JsonConverters/EnumConverterFactory.cs @@ -1,10 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; -using System.Threading.Tasks; namespace SharedKernel.JsonConverters; @@ -21,12 +16,12 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer var underlyingType = Nullable.GetUnderlyingType(typeToConvert); if (underlyingType != null) // It's a nullable enum { - Type converterType = typeof(NullableEnumConverter<>).MakeGenericType(underlyingType); + var converterType = typeof(NullableEnumConverter<>).MakeGenericType(underlyingType); return (JsonConverter)Activator.CreateInstance(converterType)!; } else // Non-nullable enum { - Type converterType = typeof(NonNullableEnumConverter<>).MakeGenericType(typeToConvert); + var converterType = typeof(NonNullableEnumConverter<>).MakeGenericType(typeToConvert); return (JsonConverter)Activator.CreateInstance(converterType)!; } } diff --git a/src/SharedKernel/Logging/Middleware/CappedResponseBodyStream.cs b/src/SharedKernel/Logging/Middleware/CappedResponseBodyStream.cs index d6ecdfb..95654df 100644 --- a/src/SharedKernel/Logging/Middleware/CappedResponseBodyStream.cs +++ b/src/SharedKernel/Logging/Middleware/CappedResponseBodyStream.cs @@ -2,32 +2,18 @@ namespace SharedKernel.Logging.Middleware; -internal sealed class CappedResponseBodyStream : Stream +internal sealed class CappedResponseBodyStream(Stream inner, int capBytes) : Stream { - private readonly byte[] _buf; - private readonly int _capBytes; - private readonly Stream _inner; - private int _bufLen; - - public CappedResponseBodyStream(Stream inner, int capBytes) - { - _inner = inner; - _capBytes = capBytes; - _buf = ArrayPool.Shared.Rent(_capBytes); - _bufLen = 0; - TotalWritten = 0; - } - + private readonly byte[] _buffer = ArrayPool.Shared.Rent(capBytes); + private int _bufferLength; + private bool _disposed; public long TotalWritten { get; private set; } - public ReadOnlyMemory Captured => new(_buf, 0, _bufLen); + public ReadOnlyMemory Captured => new(_buffer, 0, _bufferLength); public override bool CanRead => false; - public override bool CanSeek => false; - public override bool CanWrite => true; - public override long Length => throw new NotSupportedException(); public override long Position @@ -36,80 +22,67 @@ public override long Position set => throw new NotSupportedException(); } - private void Capture(ReadOnlySpan src) + private void Capture(ReadOnlySpan source) { - if (_bufLen >= _capBytes) - { + if (_bufferLength >= capBytes) return; - } - var toCopy = Math.Min(_capBytes - _bufLen, src.Length); + var toCopy = Math.Min(capBytes - _bufferLength, source.Length); if (toCopy <= 0) - { return; - } - src[..toCopy] - .CopyTo(_buf.AsSpan(_bufLen)); - _bufLen += toCopy; + source[..toCopy] + .CopyTo(_buffer.AsSpan(_bufferLength)); + _bufferLength += toCopy; } public override void Write(byte[] buffer, int offset, int count) { - _inner.Write(buffer, offset, count); + inner.Write(buffer, offset, count); TotalWritten += count; - Capture(new ReadOnlySpan(buffer, offset, count)); + Capture(buffer.AsSpan(offset, count)); } public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { - await _inner.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + await inner.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); TotalWritten += count; - Capture(new ReadOnlySpan(buffer, offset, count)); + Capture(buffer.AsSpan(offset, count)); } public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { - var vt = _inner.WriteAsync(buffer, cancellationToken); + var task = inner.WriteAsync(buffer, cancellationToken); TotalWritten += buffer.Length; Capture(buffer.Span); - return vt; + return task; } - public override void Flush() - { - _inner.Flush(); - } + public override void Flush() => inner.Flush(); - public override Task FlushAsync(CancellationToken cancellationToken) - { - return _inner.FlushAsync(cancellationToken); - } + public override Task FlushAsync(CancellationToken cancellationToken) => inner.FlushAsync(cancellationToken); protected override void Dispose(bool disposing) { - try - { - base.Dispose(disposing); - } - finally - { - ArrayPool.Shared.Return(_buf); - } - } + if (_disposed) + return; - public override int Read(byte[] buffer, int offset, int count) - { - throw new NotSupportedException(); + _disposed = true; + ArrayPool.Shared.Return(_buffer); + base.Dispose(disposing); } - public override long Seek(long offset, SeekOrigin origin) + public override ValueTask DisposeAsync() { - throw new NotSupportedException(); - } + if (_disposed) + return ValueTask.CompletedTask; - public override void SetLength(long value) - { - throw new NotSupportedException(); + _disposed = true; + ArrayPool.Shared.Return(_buffer); + return ValueTask.CompletedTask; } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); } \ No newline at end of file diff --git a/src/SharedKernel/Logging/Middleware/HttpLogHelper.cs b/src/SharedKernel/Logging/Middleware/HttpLogHelper.cs index c9b0424..5693463 100644 --- a/src/SharedKernel/Logging/Middleware/HttpLogHelper.cs +++ b/src/SharedKernel/Logging/Middleware/HttpLogHelper.cs @@ -1,179 +1,160 @@ using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.Net.Http.Headers; -using System.Threading; namespace SharedKernel.Logging.Middleware; internal static class HttpLogHelper { - public static async Task<(object Headers, object Body)> CaptureAsync(Stream bodyStream, - IHeaderDictionary headers, - string? contentType, - CancellationToken ct = default) - { - var redactedHeaders = RedactionHelper.RedactHeaders(headers); - - var textLike = MediaTypeUtil.IsTextLike(contentType); - var hasContentLength = headers.ContainsKey(HeaderNames.ContentLength); - var len = GetContentLengthOrNull(headers); - var hasChunked = headers.TryGetValue(HeaderNames.TransferEncoding, out _); - - if ((hasContentLength && len == 0) || - (!hasContentLength && !hasChunked && string.IsNullOrWhiteSpace(contentType))) - { - return (redactedHeaders, new Dictionary()); - } - - if (!textLike) - { - return (redactedHeaders, LogFormatting.Omitted( - "non-text", - len, - MediaTypeUtil.Normalize(contentType), - LoggingOptions.RequestResponseBodyMaxBytes)); - } - - var (raw, truncated) = await ReadLimitedAsync(bodyStream, LoggingOptions.RequestResponseBodyMaxBytes, ct); - if (truncated) - { - return (redactedHeaders, LogFormatting.Omitted( - "exceeds-limit", - LoggingOptions.RequestResponseBodyMaxBytes, - MediaTypeUtil.Normalize(contentType), - LoggingOptions.RequestResponseBodyMaxBytes)); - } - - var body = RedactionHelper.RedactBody(contentType, raw); - return (redactedHeaders, body); - } - - public static async Task<(object Headers, object Body)> CaptureAsync(Dictionary> headers, - Func> rawReader, - string? contentType, - CancellationToken ct = default) - { - var redactedHeaders = RedactionHelper.RedactHeaders(headers); - - if (!MediaTypeUtil.IsTextLike(contentType)) - { - return (redactedHeaders, new Dictionary()); - } - - var raw = await rawReader(); - - if (Utf8ByteCount(raw) > LoggingOptions.RequestResponseBodyMaxBytes) - { - return (redactedHeaders, LogFormatting.Omitted( - "exceeds-limit", - Utf8ByteCount(raw), - MediaTypeUtil.Normalize(contentType), - LoggingOptions.RequestResponseBodyMaxBytes)); - } - - var body = RedactionHelper.RedactBody(contentType, raw); - return (redactedHeaders, body); - } - - public static Dictionary> CreateHeadersDictionary(HttpRequestMessage req) - { - var dict = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var h in req.Headers) - { - dict[h.Key] = h.Value; - } - - var contentHeaders = req.Content?.Headers; - - if (contentHeaders != null) - { - foreach (var h in contentHeaders) - { - dict[h.Key] = h.Value; - } - } - - return dict; - } - - public static Dictionary> CreateHeadersDictionary(HttpResponseMessage res) - { - var dict = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var h in res.Headers) - { - dict[h.Key] = h.Value; - } - - var ch = res.Content.Headers; - - foreach (var h in ch) - { - dict[h.Key] = h.Value; - } - - return dict; - } - - internal static bool IsTextLike(string? mediaType) - { - return MediaTypeUtil.IsTextLike(mediaType); - } - - private static long? GetContentLengthOrNull(IHeaderDictionary headers) - { - if (headers.TryGetValue("Content-Length", out var clVal) && - long.TryParse(clVal.ToString(), out var cl)) - { - return cl; - } - - return null; - } - - private static async Task<(string text, bool truncated)> ReadLimitedAsync(Stream s, - int maxBytes, - CancellationToken ct = default) - { - s.Seek(0, SeekOrigin.Begin); - - using var ms = new MemoryStream(maxBytes); - var buf = new byte[Math.Min(8192, maxBytes)]; - var total = 0; - - while (total < maxBytes) - { - var toRead = Math.Min(buf.Length, maxBytes - total); - var read = await s.ReadAsync(buf.AsMemory(0, toRead), ct); - if (read == 0) - { - break; - } - - await ms.WriteAsync(buf.AsMemory(0, read), ct); - total += read; - } - - var truncated = false; - if (total == maxBytes) - { - var probe = new byte[1]; - var read = await s.ReadAsync(probe.AsMemory(0, 1), ct); - if (read > 0) - { - truncated = true; - if (s.CanSeek) + public static async Task<(object Headers, object Body)> CaptureAsync( + Stream bodyStream, + IHeaderDictionary headers, + string? contentType, + CancellationToken ct = default) + { + var redactedHeaders = RedactionHelper.RedactHeaders(headers); + + var textLike = MediaTypeUtil.IsTextLike(contentType); + var hasContentLength = headers.ContainsKey(HeaderNames.ContentLength); + var contentLength = GetContentLengthOrNull(headers); + var hasChunked = headers.ContainsKey(HeaderNames.TransferEncoding); + + // Empty body detection + if ((hasContentLength && contentLength == 0) || + (!hasContentLength && !hasChunked && string.IsNullOrWhiteSpace(contentType))) + { + return (redactedHeaders, new Dictionary()); + } + + if (!textLike) + { + return (redactedHeaders, LogFormatting.Omitted( + "non-text", + contentLength, + MediaTypeUtil.Normalize(contentType), + LoggingOptions.RequestResponseBodyMaxBytes)); + } + + var (raw, truncated) = await ReadLimitedAsync(bodyStream, LoggingOptions.RequestResponseBodyMaxBytes, ct); + + if (truncated) + { + return (redactedHeaders, LogFormatting.Omitted( + "exceeds-limit", + LoggingOptions.RequestResponseBodyMaxBytes, + MediaTypeUtil.Normalize(contentType), + LoggingOptions.RequestResponseBodyMaxBytes)); + } + + var body = RedactionHelper.RedactBody(contentType, raw); + return (redactedHeaders, body); + } + + public static async Task<(object Headers, object Body)> CaptureAsync( + Dictionary> headers, + Func> rawReader, + string? contentType, + CancellationToken ct = default) + { + var redactedHeaders = RedactionHelper.RedactHeaders(headers); + + if (!MediaTypeUtil.IsTextLike(contentType)) + return (redactedHeaders, new Dictionary()); + + var raw = await rawReader(); + var byteCount = Encoding.UTF8.GetByteCount(raw); + + if (byteCount > LoggingOptions.RequestResponseBodyMaxBytes) + { + return (redactedHeaders, LogFormatting.Omitted( + "exceeds-limit", + byteCount, + MediaTypeUtil.Normalize(contentType), + LoggingOptions.RequestResponseBodyMaxBytes)); + } + + var body = RedactionHelper.RedactBody(contentType, raw); + return (redactedHeaders, body); + } + + public static Dictionary> CreateHeadersDictionary(HttpRequestMessage request) + { + var dict = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var header in request.Headers) + dict[header.Key] = header.Value; + + if (request.Content?.Headers is { } contentHeaders) + { + foreach (var header in contentHeaders) + dict[header.Key] = header.Value; + } + + return dict; + } + + public static Dictionary> CreateHeadersDictionary(HttpResponseMessage response) + { + var dict = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var header in response.Headers) + dict[header.Key] = header.Value; + + foreach (var header in response.Content.Headers) + dict[header.Key] = header.Value; + + return dict; + } + + private static long? GetContentLengthOrNull(IHeaderDictionary headers) + { + if (headers.TryGetValue(HeaderNames.ContentLength, out var value) && + long.TryParse(value.ToString(), out var contentLength)) + { + return contentLength; + } + return null; + } + + private static async Task<(string text, bool truncated)> ReadLimitedAsync( + Stream stream, + int maxBytes, + CancellationToken ct = default) + { + stream.Seek(0, SeekOrigin.Begin); + + using var memoryStream = new MemoryStream(maxBytes); + var buffer = new byte[Math.Min(8192, maxBytes)]; + var totalRead = 0; + + while (totalRead < maxBytes) + { + var toRead = Math.Min(buffer.Length, maxBytes - totalRead); + var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, toRead), ct); + + if (bytesRead == 0) + break; + + await memoryStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct); + totalRead += bytesRead; + } + + var truncated = false; + + if (totalRead == maxBytes) + { + var probe = new byte[1]; + var probeRead = await stream.ReadAsync(probe.AsMemory(0, 1), ct); + + if (probeRead > 0) { - s.Seek(-read, SeekOrigin.Current); + truncated = true; + if (stream.CanSeek) + stream.Seek(-probeRead, SeekOrigin.Current); } - } - } + } - s.Seek(0, SeekOrigin.Begin); - return (Encoding.UTF8.GetString(ms.ToArray()), truncated); - } - - private static int Utf8ByteCount(string s) - { - return Encoding.UTF8.GetByteCount(s); - } + stream.Seek(0, SeekOrigin.Begin); + return (Encoding.UTF8.GetString(memoryStream.ToArray()), truncated); + } } \ No newline at end of file diff --git a/src/SharedKernel/Logging/Middleware/LogFormatting.cs b/src/SharedKernel/Logging/Middleware/LogFormatting.cs index 984cdec..8a2b757 100644 --- a/src/SharedKernel/Logging/Middleware/LogFormatting.cs +++ b/src/SharedKernel/Logging/Middleware/LogFormatting.cs @@ -1,7 +1,15 @@ -namespace SharedKernel.Logging.Middleware; +using System.Text.Json; + +namespace SharedKernel.Logging.Middleware; internal static class LogFormatting { + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false, + PropertyNamingPolicy = null + }; + public static object Omitted(string reason, long? lengthBytes, string? mediaType, int thresholdBytes) { int? sizeKb = lengthBytes.HasValue ? (int)Math.Round(lengthBytes.Value / 1024d) : null; @@ -14,4 +22,28 @@ public static object Omitted(string reason, long? lengthBytes, string? mediaType ["contentType"] = MediaTypeUtil.Normalize(mediaType) }; } + + /// + /// Converts the body object to a JSON string representation. + /// This prevents Elasticsearch field explosion by storing bodies as strings instead of objects. + /// + public static string ToJsonString(object? body) + { + switch (body) + { + case null: + return "{}"; + case string s: + return s; + default: + try + { + return JsonSerializer.Serialize(body, JsonOptions); + } + catch + { + return "{}"; + } + } + } } \ No newline at end of file diff --git a/src/SharedKernel/Logging/Middleware/LoggingExtensions.cs b/src/SharedKernel/Logging/Middleware/LoggingExtensions.cs index 3629480..b72623c 100644 --- a/src/SharedKernel/Logging/Middleware/LoggingExtensions.cs +++ b/src/SharedKernel/Logging/Middleware/LoggingExtensions.cs @@ -10,9 +10,7 @@ public static class LoggingExtensions public static WebApplication UseRequestLogging(this WebApplication app) { if (Log.Logger.IsEnabled(LogEventLevel.Information)) - { app.UseMiddleware(); - } return app; } @@ -20,9 +18,7 @@ public static WebApplication UseRequestLogging(this WebApplication app) public static WebApplicationBuilder AddOutboundLoggingHandler(this WebApplicationBuilder builder) { if (Log.Logger.IsEnabled(LogEventLevel.Information)) - { builder.Services.AddTransient(); - } return builder; } @@ -30,9 +26,7 @@ public static WebApplicationBuilder AddOutboundLoggingHandler(this WebApplicatio public static IHttpClientBuilder AddOutboundLoggingHandler(this IHttpClientBuilder builder) { if (Log.Logger.IsEnabled(LogEventLevel.Information)) - { builder.AddHttpMessageHandler(); - } return builder; } diff --git a/src/SharedKernel/Logging/Middleware/LoggingOptions.cs b/src/SharedKernel/Logging/Middleware/LoggingOptions.cs index 568d052..0593c9a 100644 --- a/src/SharedKernel/Logging/Middleware/LoggingOptions.cs +++ b/src/SharedKernel/Logging/Middleware/LoggingOptions.cs @@ -1,49 +1,41 @@ -namespace SharedKernel.Logging.Middleware; +using System.Collections.Frozen; + +namespace SharedKernel.Logging.Middleware; public static class LoggingOptions { - // keeps original defaults/behavior - public const int RequestResponseBodyMaxBytes = 16 * 1024; // HttpLogHelper.MaxBodyBytes - public const int RedactionMaxPropertyBytes = 2 * 1024; // RedactionHelper.MaxPropertyBytes - public const long ResponseBufferLimitBytes = 10L * 1024 * 1024; // FileBufferingWriteStream limit + public const int RequestResponseBodyMaxBytes = 16 * 1024; + public const int RedactionMaxPropertyBytes = 2 * 1024; - // Sensitive words to redact in headers and JSON bodies - public static readonly HashSet SensitiveKeywords = new(StringComparer.OrdinalIgnoreCase) + /// + /// Sensitive keywords to redact in headers and JSON bodies. + /// Uses FrozenSet for optimized lookups on static data. + /// + public static readonly FrozenSet SensitiveKeywords = new[] { - "pwd", - "pass", - "secret", - "token", - "cookie", - "auth", - "pan", - "cvv", - "cvc", - "cardholder", - "bindingid", - "ssn", - "tin", - "iban", - "swift", - "bankaccount", - "notboundcard" - }; - + "pwd", "pass", "secret", "token", "cookie", "auth", + "pan", "cvv", "cvc", "cardholder", "bindingid", + "ssn", "tin", "iban", "swift", "bankaccount", "notboundcard" + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); - // was HttpLogHelper.TextLikeMedia - public static readonly string[] TextLikeMediaPrefixes = - [ + /// + /// Media type prefixes considered text-like for logging purposes. + /// + public static readonly FrozenSet TextLikeMediaPrefixes = new[] + { "application/json", "application/x-www-form-urlencoded", "text/" - ]; + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); - // was RequestLoggingMiddleware.PathsToIgnore - public static readonly HashSet PathsToIgnore = new(StringComparer.OrdinalIgnoreCase) + /// + /// Paths to ignore for request logging. + /// + public static readonly FrozenSet PathsToIgnore = new[] { "/openapi", "/above-board", "/favicon.ico", "/swagger" - }; + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); } \ No newline at end of file diff --git a/src/SharedKernel/Logging/Middleware/MediaTypeUtil.cs b/src/SharedKernel/Logging/Middleware/MediaTypeUtil.cs index 5d7a1a9..03a6ab0 100644 --- a/src/SharedKernel/Logging/Middleware/MediaTypeUtil.cs +++ b/src/SharedKernel/Logging/Middleware/MediaTypeUtil.cs @@ -7,19 +7,17 @@ internal static class MediaTypeUtil public static string? Normalize(string? contentType) { if (string.IsNullOrWhiteSpace(contentType)) - { return null; - } try { var mt = MediaTypeHeaderValue.Parse(contentType); - return mt.MediaType.Value; // e.g. "application/json" + return mt.MediaType.Value; } catch { - var semi = contentType.IndexOf(';'); - return (semi >= 0 ? contentType[..semi] : contentType).Trim(); + var semiIndex = contentType.IndexOf(';'); + return (semiIndex >= 0 ? contentType[..semiIndex] : contentType).Trim(); } } @@ -31,15 +29,32 @@ public static bool IsJson(string? contentType) mt.EndsWith("+json", StringComparison.OrdinalIgnoreCase)); } + public static bool IsFormUrlEncoded(string? contentType) => + string.Equals(Normalize(contentType), "application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase); + + public static bool IsMultipartForm(string? contentType) => + Normalize(contentType)?.StartsWith("multipart/form-data", StringComparison.OrdinalIgnoreCase) == true; + + public static bool IsFormLike(string? contentType) => + IsFormUrlEncoded(contentType) || IsMultipartForm(contentType); + public static bool IsTextLike(string? contentType) { var mt = Normalize(contentType); if (string.IsNullOrWhiteSpace(mt)) - { return false; + + // Check +json suffix + if (mt.EndsWith("+json", StringComparison.OrdinalIgnoreCase)) + return true; + + // Check against known text-like prefixes + foreach (var prefix in LoggingOptions.TextLikeMediaPrefixes) + { + if (mt.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return true; } - return LoggingOptions.TextLikeMediaPrefixes.Any(p => mt.StartsWith(p, StringComparison.OrdinalIgnoreCase)) || - mt.EndsWith("+json", StringComparison.OrdinalIgnoreCase); + return false; } } \ No newline at end of file diff --git a/src/SharedKernel/Logging/Middleware/OutboundLoggingHandler.cs b/src/SharedKernel/Logging/Middleware/OutboundLoggingHandler.cs index 2fad126..1f4f46f 100644 --- a/src/SharedKernel/Logging/Middleware/OutboundLoggingHandler.cs +++ b/src/SharedKernel/Logging/Middleware/OutboundLoggingHandler.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; namespace SharedKernel.Logging.Middleware; @@ -9,179 +8,143 @@ internal sealed class OutboundLoggingHandler(ILogger log protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - var sw = Stopwatch.GetTimestamp(); + var timestamp = Stopwatch.GetTimestamp(); - var reqHdrDict = HttpLogHelper.CreateHeadersDictionary(request); - var reqMedia = request.Content?.Headers.ContentType?.MediaType; - var reqLen = request.Content?.Headers.ContentLength; + var (reqHeaders, reqBody) = await CaptureRequestAsync(request, cancellationToken); - object reqHeaders; - object reqBody; + var response = await base.SendAsync(request, cancellationToken); + + var elapsedMs = Stopwatch.GetElapsedTime(timestamp) + .TotalMilliseconds; + + var (resHeaders, resBody) = await CaptureResponseAsync(response, cancellationToken); - var isFormLike = - string.Equals(reqMedia, "application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) || - string.Equals(reqMedia, "multipart/form-data", StringComparison.OrdinalIgnoreCase); + LogHttpOut(request, response, reqHeaders, reqBody, resHeaders, resBody, elapsedMs); + + return response; + } + + private static async Task<(object Headers, object Body)> CaptureRequestAsync(HttpRequestMessage request, + CancellationToken ct) + { + var headerDict = HttpLogHelper.CreateHeadersDictionary(request); + var redactedHeaders = RedactionHelper.RedactHeaders(headerDict); if (request.Content is null) + return (redactedHeaders, new Dictionary()); + + var mediaType = request.Content.Headers.ContentType?.MediaType; + var contentLength = request.Content.Headers.ContentLength; + + // MULTIPART: Never enumerate or read - it corrupts internal state + if (request.Content is MultipartFormDataContent) { - reqHeaders = RedactionHelper.RedactHeaders(reqHdrDict); - reqBody = new Dictionary(); + return (redactedHeaders, new Dictionary + { + ["_type"] = "multipart/form-data", + ["_contentLength"] = contentLength, + ["_note"] = "multipart body not captured to preserve request integrity" + }); } - else if (isFormLike) - { - reqHeaders = RedactionHelper.RedactHeaders(reqHdrDict); - if (reqLen is null or > LoggingOptions.RequestResponseBodyMaxBytes) - { - reqBody = LogFormatting.Omitted("form-large-or-unknown", - reqLen, - reqMedia, - LoggingOptions.RequestResponseBodyMaxBytes); - } - else + // STREAM CONTENT: Not safe to read - would consume the stream + if (request.Content is StreamContent) + { + return (redactedHeaders, new Dictionary { - var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); - - switch (request.Content) - { - case FormUrlEncodedContent: - { - var raw = await request.Content.ReadAsStringAsync(cancellationToken); - var parsed = QueryHelpers.ParseQuery("?" + raw); - foreach (var kvp in parsed) - { - var k = kvp.Key; - if (string.IsNullOrEmpty(k)) - { - continue; - } - - fields[k] = RedactionHelper.RedactFormValue(k, string.Join(";", kvp.Value.ToArray())); - } - - break; - } - case MultipartFormDataContent mfd: - { - var fileCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); - var fileSizes = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var part in mfd) - { - var cd = part.Headers.ContentDisposition; - var name = cd?.Name?.Trim('"') ?? "field"; - var isFile = cd != null && - (!string.IsNullOrEmpty(cd.FileName) || !string.IsNullOrEmpty(cd.FileNameStar)); - - if (isFile) - { - fileCounts.TryGetValue(name, out var c); - fileCounts[name] = c + 1; - - if (part.Headers.ContentLength.HasValue) - { - fileSizes.TryGetValue(name, out var b); - fileSizes[name] = b + part.Headers.ContentLength.Value; - } - - continue; - } - - var value = await part.ReadAsStringAsync(cancellationToken); - fields[name] = RedactionHelper.RedactFormValue(name, value); - } - - foreach (var name in fileCounts.Keys) - { - var count = fileCounts[name]; - fileSizes.TryGetValue(name, out var bytes); - var hasSize = bytes > 0; - var sizeKb = hasSize ? (int)Math.Round(bytes / 1024d) : (int?)null; - - var place = count == 1 - ? hasSize ? $"[OMITTED: file {sizeKb}KB]" : "[OMITTED: file]" - : hasSize - ? $"[OMITTED: {count} files {sizeKb}KB]" - : $"[OMITTED: {count} files]"; - - if (fields.TryGetValue(name, out var existing) && !string.IsNullOrWhiteSpace(existing)) - { - fields[name] = $"{existing}; {place}"; - } - else - { - fields[name] = place; - } - } - - break; - } - } - - reqBody = fields; - } + ["_type"] = mediaType, + ["_contentLength"] = contentLength, + ["_note"] = "stream body not captured to preserve request integrity" + }); } - else if (!HttpLogHelper.IsTextLike(reqMedia) || !reqLen.HasValue || - reqLen.Value > LoggingOptions.RequestResponseBodyMaxBytes) + + // NON-TEXT: Just log metadata + if (!MediaTypeUtil.IsTextLike(mediaType)) { - reqHeaders = RedactionHelper.RedactHeaders(reqHdrDict); - var reason = !HttpLogHelper.IsTextLike(reqMedia) ? "non-text" : "exceeds-limit-or-unknown"; - reqBody = LogFormatting.Omitted(reason, reqLen, reqMedia, LoggingOptions.RequestResponseBodyMaxBytes); + return (redactedHeaders, + LogFormatting.Omitted("non-text", contentLength, mediaType, LoggingOptions.RequestResponseBodyMaxBytes)); } - else + + // TOO LARGE: Don't read + if (contentLength is > LoggingOptions.RequestResponseBodyMaxBytes) { - (reqHeaders, reqBody) = await HttpLogHelper.CaptureAsync(reqHdrDict, () => request.Content == null - ? Task.FromResult(string.Empty) - : request.Content.ReadAsStringAsync(cancellationToken), reqMedia, cancellationToken); + return (redactedHeaders, + LogFormatting.Omitted("exceeds-limit", contentLength, mediaType, LoggingOptions.RequestResponseBodyMaxBytes)); } - var response = await base.SendAsync(request, cancellationToken); - var elapsed = Stopwatch.GetElapsedTime(sw) - .TotalMilliseconds; + // SAFE TO READ: ByteArrayContent, StringContent, FormUrlEncodedContent, ReadOnlyMemoryContent + // These are all backed by byte arrays and support multiple reads + var raw = await request.Content.ReadAsStringAsync(ct); + + // Double-check size after reading (in case contentLength was null) + if (raw.Length > LoggingOptions.RequestResponseBodyMaxBytes) + { + return (redactedHeaders, + LogFormatting.Omitted("exceeds-limit", raw.Length, mediaType, LoggingOptions.RequestResponseBodyMaxBytes)); + } + + var body = RedactionHelper.RedactBody(mediaType, raw); + return (redactedHeaders, body); + } + + private static async Task<(object Headers, object Body)> CaptureResponseAsync(HttpResponseMessage response, + CancellationToken ct) + { + var headerDict = HttpLogHelper.CreateHeadersDictionary(response); + var redactedHeaders = RedactionHelper.RedactHeaders(headerDict); - var resHdrDict = HttpLogHelper.CreateHeadersDictionary(response); - var resMedia = response.Content.Headers.ContentType?.MediaType; - var resLen = response.Content.Headers.ContentLength; + var mediaType = response.Content.Headers.ContentType?.MediaType; + var contentLength = response.Content.Headers.ContentLength; - object resHeaders; - object resBody; + // Empty response (explicit Content-Length: 0) + if (contentLength == 0) + return (redactedHeaders, new Dictionary()); - if (!HttpLogHelper.IsTextLike(resMedia) || !resLen.HasValue || - resLen.Value > LoggingOptions.RequestResponseBodyMaxBytes) + // Non-text content + if (!MediaTypeUtil.IsTextLike(mediaType)) { - resHeaders = RedactionHelper.RedactHeaders(resHdrDict); - - if (resLen == 0) - { - resBody = new Dictionary(); - } - else - { - var reason = !HttpLogHelper.IsTextLike(resMedia) ? "non-text" : "exceeds-limit-or-unknown"; - resBody = LogFormatting.Omitted(reason, resLen, resMedia, LoggingOptions.RequestResponseBodyMaxBytes); - } + return (redactedHeaders, + LogFormatting.Omitted("non-text", contentLength, mediaType, LoggingOptions.RequestResponseBodyMaxBytes)); } - else + + // Known to be too large + if (contentLength > LoggingOptions.RequestResponseBodyMaxBytes) { - (resHeaders, resBody) = await HttpLogHelper.CaptureAsync(resHdrDict, () => response.Content.ReadAsStringAsync(cancellationToken), resMedia, cancellationToken); + return (redactedHeaders, + LogFormatting.Omitted("exceeds-limit", contentLength, mediaType, LoggingOptions.RequestResponseBodyMaxBytes)); } - var hostPath = request.RequestUri is null ? "" : request.RequestUri.GetLeftPart(UriPartial.Path); + // SAFE TO READ: Response bodies are always safe - they've already been received + // This includes chunked responses (no Content-Length header) + return await HttpLogHelper.CaptureAsync( + headerDict, + () => response.Content.ReadAsStringAsync(ct), + mediaType, + ct); + } + + private void LogHttpOut(HttpRequestMessage request, + HttpResponseMessage response, + object reqHeaders, + object reqBody, + object resHeaders, + object resBody, + double elapsedMs) + { + var hostPath = request.RequestUri?.GetLeftPart(UriPartial.Path) ?? ""; var scope = new Dictionary { - ["RequestHeaders"] = reqHeaders, - ["RequestBody"] = reqBody, - ["ResponseHeaders"] = resHeaders, - ["ResponseBody"] = resBody, - ["ElapsedMs"] = elapsed, + ["RequestHeaders"] = LogFormatting.ToJsonString(reqHeaders), + ["RequestBody"] = LogFormatting.ToJsonString(reqBody), + ["ResponseHeaders"] = LogFormatting.ToJsonString(resHeaders), + ["ResponseBody"] = LogFormatting.ToJsonString(resBody), + ["ElapsedMs"] = elapsedMs, ["Kind"] = "HttpOut" }; if (!string.IsNullOrEmpty(request.RequestUri?.Query)) - { scope["Query"] = request.RequestUri!.Query; - } using (logger.BeginScope(scope)) { @@ -190,9 +153,7 @@ protected override async Task SendAsync(HttpRequestMessage request.Method, hostPath, (int)response.StatusCode, - elapsed); + elapsedMs); } - - return response; } } \ No newline at end of file diff --git a/src/SharedKernel/Logging/Middleware/ReductionHelper.cs b/src/SharedKernel/Logging/Middleware/ReductionHelper.cs index e47f366..13adcc8 100644 --- a/src/SharedKernel/Logging/Middleware/ReductionHelper.cs +++ b/src/SharedKernel/Logging/Middleware/ReductionHelper.cs @@ -8,201 +8,187 @@ namespace SharedKernel.Logging.Middleware; internal static class RedactionHelper { - // ------- Headers ------- - - public static Dictionary RedactHeaders(IHeaderDictionary headers) - { - return headers.ToDictionary( - h => h.Key, - h => LoggingOptions.SensitiveKeywords.Any(k => h.Key.Contains(k, StringComparison.OrdinalIgnoreCase)) - ? "[REDACTED]" - : h.Value.ToString()); - } - - public static Dictionary RedactHeaders(Dictionary> headers) - { - return headers.ToDictionary( - kvp => kvp.Key, - kvp => LoggingOptions.SensitiveKeywords.Any(k => kvp.Key.Contains(k, StringComparison.OrdinalIgnoreCase)) - ? "[REDACTED]" - : string.Join(";", kvp.Value)); - } - - // ------- Bodies (JSON, x-www-form-urlencoded, text fallback) ------- - - public static object RedactBody(string? contentType, string raw) - { - if (string.IsNullOrWhiteSpace(raw)) - { - return new Dictionary(); - } - - // JSON (including +json) - if (MediaTypeUtil.IsJson(contentType)) - { - try - { - var el = JsonSerializer.Deserialize(raw); - return RedactElement(el); - } - catch (JsonException) - { - return new Dictionary - { - ["invalidJson"] = true - }; - } - } - - // application/x-www-form-urlencoded - if (string.Equals(MediaTypeUtil.Normalize(contentType), - "application/x-www-form-urlencoded", - StringComparison.OrdinalIgnoreCase)) - { - var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - var parsed = QueryHelpers.ParseQuery("?" + raw); - foreach (var kvp in parsed) - { - var k = kvp.Key; - - if (string.IsNullOrEmpty(k)) - { - continue; - } + private const string Redacted = "[REDACTED]"; + + public static Dictionary RedactHeaders(IHeaderDictionary headers) => + headers.ToDictionary( + h => h.Key, + h => IsSensitiveKey(h.Key) ? Redacted : h.Value.ToString()); + + public static Dictionary RedactHeaders(Dictionary> headers) => + headers.ToDictionary( + kvp => kvp.Key, + kvp => IsSensitiveKey(kvp.Key) ? Redacted : string.Join(";", kvp.Value)); + + + public static object RedactBody(string? contentType, string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return new Dictionary(); + + // JSON (including +json) + if (MediaTypeUtil.IsJson(contentType)) + return RedactJsonBody(raw); + + // application/x-www-form-urlencoded + if (MediaTypeUtil.IsFormUrlEncoded(contentType)) + return RedactFormUrlEncodedBody(raw); + + // Plain text fallback + return RedactPlainTextBody(raw); + } + + private static object RedactJsonBody(string raw) + { + try + { + var element = JsonSerializer.Deserialize(raw); + return RedactElement(element); + } + catch (JsonException) + { + return new Dictionary { ["invalidJson"] = true }; + } + } + + private static Dictionary RedactFormUrlEncodedBody(string raw) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + var parsed = QueryHelpers.ParseQuery("?" + raw); + + foreach (var kvp in parsed) + { + if (string.IsNullOrEmpty(kvp.Key)) + continue; var joined = string.Join(";", kvp.Value.ToArray()); - dict[k] = RedactFormValue(k, joined); - } - - return dict; - } - - var rawBytes = Encoding.UTF8.GetByteCount(raw); - - if (rawBytes > LoggingOptions.RedactionMaxPropertyBytes) - { - return new Dictionary - { - ["text"] = $"[OMITTED: exceeds-limit ~{rawBytes / 1024}KB]" - }; - } - - var val = LoggingOptions.SensitiveKeywords.Any(s => raw.Contains(s, StringComparison.OrdinalIgnoreCase)) - ? "[REDACTED]" - : raw; - - return new Dictionary - { - ["text"] = val - }; - } - - // ------- Forms (fields only; add file placeholders) ------- - - public static Dictionary RedactFormFields(IFormCollection form) - { - var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); - - // text fields - foreach (var kvp in form) - { - var raw = string.Join(";", kvp.Value.ToArray()); - fields[kvp.Key] = RedactFormValue(kvp.Key, raw); - } - - // file placeholders - if (form.Files.Count <= 0) - { - return fields; - } - - var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); - var sizes = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var f in form.Files) - { - var key = f.Name; - counts.TryGetValue(key, out var c); - counts[key] = c + 1; - - sizes.TryGetValue(key, out var b); - sizes[key] = b + f.Length; - } - - foreach (var key in counts.Keys) - { - var count = counts[key]; - var sizeKb = (int)Math.Round(sizes[key] / 1024d); - - var place = count == 1 - ? $"[OMITTED: file {sizeKb}KB]" - : $"[OMITTED: {count} files {sizeKb}KB]"; - - if (fields.TryGetValue(key, out var existing) && !string.IsNullOrWhiteSpace(existing)) - { - fields[key] = $"{existing}; {place}"; - } - else - { - fields[key] = place; - } - } - - return fields; - } - - // ------- Helpers ------- - - private static object RedactElement(JsonElement el) - { - return el.ValueKind switch - { - JsonValueKind.Object => el.EnumerateObject() - .ToDictionary( - p => p.Name, - p => LoggingOptions.SensitiveKeywords.Any(k => - p.Name.Contains(k, StringComparison.OrdinalIgnoreCase)) - ? "[REDACTED]" - : RedactElement(p.Value)), - JsonValueKind.Array => el.EnumerateArray() - .Select(RedactElement) - .ToArray(), - JsonValueKind.String => RedactString(el.GetString()!), - JsonValueKind.Number => el.TryGetInt64(out var i) ? i : - el.TryGetDouble(out var d) ? d : - decimal.TryParse(el.GetRawText(), NumberStyles.Any, CultureInfo.InvariantCulture, out var m) ? m : - el.GetRawText(), - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null => null!, - _ => el.GetRawText() - }; - } - - private static string RedactString(string value) - { - var bytes = Encoding.UTF8.GetByteCount(value); - if (bytes <= LoggingOptions.RedactionMaxPropertyBytes) - { - return LoggingOptions.SensitiveKeywords.Any(s => value.Contains(s, StringComparison.OrdinalIgnoreCase)) - ? "[REDACTED]" - : value; - } + dict[kvp.Key] = RedactFormValue(kvp.Key, joined); + } - return $"[OMITTED: exceeds-limit ~{bytes / 1024}KB]"; - } + return dict; + } - internal static string RedactFormValue(string key, string value) - { - if (LoggingOptions.SensitiveKeywords.Any(k => - key.Contains(k, StringComparison.OrdinalIgnoreCase) || - value.Contains(k, StringComparison.OrdinalIgnoreCase))) - { - return "[REDACTED]"; - } + private static Dictionary RedactPlainTextBody(string raw) + { + var rawBytes = Encoding.UTF8.GetByteCount(raw); - var bytes = Encoding.UTF8.GetByteCount(value); - - return bytes > LoggingOptions.RedactionMaxPropertyBytes ? $"[OMITTED: exceeds-limit ~{bytes / 1024}KB]" : value; - } + if (rawBytes > LoggingOptions.RedactionMaxPropertyBytes) + { + return new Dictionary + { + ["text"] = $"[OMITTED: exceeds-limit ~{rawBytes / 1024}KB]" + }; + } + + var value = ContainsSensitiveKeyword(raw) ? Redacted : raw; + return new Dictionary { ["text"] = value }; + } + + + public static Dictionary RedactFormFields(IFormCollection form) + { + var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Text fields + foreach (var kvp in form) + { + var raw = string.Join(";", kvp.Value.ToArray()); + fields[kvp.Key] = RedactFormValue(kvp.Key, raw); + } + + // File placeholders + AddFilePlaceholders(form.Files, fields); + + return fields; + } + + private static void AddFilePlaceholders(IFormFileCollection files, Dictionary fields) + { + if (files.Count == 0) + return; + + var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var sizes = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var file in files) + { + var key = file.Name; + counts.TryGetValue(key, out var count); + counts[key] = count + 1; + + sizes.TryGetValue(key, out var bytes); + sizes[key] = bytes + file.Length; + } + + foreach (var key in counts.Keys) + { + var count = counts[key]; + var sizeKb = (int)Math.Round(sizes[key] / 1024d); + + var placeholder = count == 1 + ? $"[OMITTED: file {sizeKb}KB]" + : $"[OMITTED: {count} files {sizeKb}KB]"; + + if (fields.TryGetValue(key, out var existing) && !string.IsNullOrWhiteSpace(existing)) + fields[key] = $"{existing}; {placeholder}"; + else + fields[key] = placeholder; + } + } + + internal static string RedactFormValue(string key, string value) + { + if (IsSensitiveKey(key) || ContainsSensitiveKeyword(value)) + return Redacted; + + var bytes = Encoding.UTF8.GetByteCount(value); + return bytes > LoggingOptions.RedactionMaxPropertyBytes + ? $"[OMITTED: exceeds-limit ~{bytes / 1024}KB]" + : value; + } + + private static object RedactElement(JsonElement element) => element.ValueKind switch + { + JsonValueKind.Object => RedactJsonObject(element), + JsonValueKind.Array => element.EnumerateArray().Select(RedactElement).ToArray(), + JsonValueKind.String => RedactString(element.GetString()!), + JsonValueKind.Number => ParseJsonNumber(element), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null!, + _ => element.GetRawText() + }; + + private static Dictionary RedactJsonObject(JsonElement element) => + element.EnumerateObject().ToDictionary( + p => p.Name, + p => IsSensitiveKey(p.Name) ? (object?)Redacted : RedactElement(p.Value)); + + private static object ParseJsonNumber(JsonElement element) + { + if (element.TryGetInt64(out var i)) + return i; + if (element.TryGetDouble(out var d)) + return d; + if (decimal.TryParse(element.GetRawText(), NumberStyles.Any, CultureInfo.InvariantCulture, out var m)) + return m; + return element.GetRawText(); + } + + private static string RedactString(string value) + { + var bytes = Encoding.UTF8.GetByteCount(value); + + if (bytes > LoggingOptions.RedactionMaxPropertyBytes) + return $"[OMITTED: exceeds-limit ~{bytes / 1024}KB]"; + + return ContainsSensitiveKeyword(value) ? Redacted : value; + } + + + private static bool IsSensitiveKey(string key) => + LoggingOptions.SensitiveKeywords.Any(k => key.Contains(k, StringComparison.OrdinalIgnoreCase)); + + private static bool ContainsSensitiveKeyword(string value) => + LoggingOptions.SensitiveKeywords.Any(k => value.Contains(k, StringComparison.OrdinalIgnoreCase)); } \ No newline at end of file diff --git a/src/SharedKernel/Logging/Middleware/RequestLoggingMiddleware.cs b/src/SharedKernel/Logging/Middleware/RequestLoggingMiddleware.cs index 5361879..cf947aa 100644 --- a/src/SharedKernel/Logging/Middleware/RequestLoggingMiddleware.cs +++ b/src/SharedKernel/Logging/Middleware/RequestLoggingMiddleware.cs @@ -8,130 +8,155 @@ internal sealed class RequestLoggingMiddleware(RequestDelegate next, ILogger context.Request.Path.StartsWithSegments(p)))) + // Skip OPTIONS requests and ignored paths + if (HttpMethods.IsOptions(context.Request.Method) || ShouldIgnorePath(context.Request.Path)) { await next(context); return; } - var normReqCt = MediaTypeUtil.Normalize(context.Request.ContentType); - var reqLen = context.Request.ContentLength; - var isFormLike = - string.Equals(normReqCt, "application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) || - string.Equals(normReqCt, "multipart/form-data", StringComparison.OrdinalIgnoreCase); + var (reqHeaders, reqBody) = await CaptureRequestAsync(context); - var looksEmpty = - reqLen == 0 || - (!reqLen.HasValue && string.IsNullOrWhiteSpace(normReqCt) && - !context.Request.Headers.ContainsKey("Transfer-Encoding")); + var originalBody = context.Response.Body; + await using var tee = new CappedResponseBodyStream(originalBody, LoggingOptions.RequestResponseBodyMaxBytes); + context.Response.Body = tee; - object reqHeaders = RedactionHelper.RedactHeaders(context.Request.Headers); - object reqBody; + var timestamp = Stopwatch.GetTimestamp(); - if (looksEmpty) + try { - reqBody = new Dictionary(); + await next(context); } - else if (isFormLike) + finally { - if (reqLen is null or > LoggingOptions.RequestResponseBodyMaxBytes) - { - reqBody = LogFormatting.Omitted("form-large-or-unknown", - reqLen, - normReqCt, - LoggingOptions.RequestResponseBodyMaxBytes); - } - else - { - var form = await context.Request.ReadFormAsync(context.RequestAborted); - reqBody = RedactionHelper.RedactFormFields(form); - } + var elapsedMs = Stopwatch.GetElapsedTime(timestamp) + .TotalMilliseconds; + var (resHeaders, resBody) = await CaptureResponseAsync(context, tee); + + context.Response.Body = originalBody; + + LogHttpIn(context, reqHeaders, reqBody, resHeaders, resBody, elapsedMs); } - else if (MediaTypeUtil.IsTextLike(normReqCt) && reqLen is <= LoggingOptions.RequestResponseBodyMaxBytes) + } + + private static bool ShouldIgnorePath(PathString path) => + path.HasValue && LoggingOptions.PathsToIgnore.Any(p => path.StartsWithSegments(p)); + + private static async Task<(object Headers, object Body)> CaptureRequestAsync(HttpContext context, + CancellationToken ct = default) + { + var request = context.Request; + var normalizedContentType = MediaTypeUtil.Normalize(request.ContentType); + var contentLength = request.ContentLength; + var redactedHeaders = RedactionHelper.RedactHeaders(request.Headers); + + // Empty body detection + var looksEmpty = contentLength == 0 || + (!contentLength.HasValue && + string.IsNullOrWhiteSpace(normalizedContentType) && + !request.Headers.ContainsKey("Transfer-Encoding")); + + if (looksEmpty) + return (redactedHeaders, new Dictionary()); + + // Form content (x-www-form-urlencoded or multipart/form-data) + if (MediaTypeUtil.IsFormLike(normalizedContentType)) { - context.Request.EnableBuffering(); - (reqHeaders, reqBody) = await HttpLogHelper.CaptureAsync( - context.Request.Body, - context.Request.Headers, - normReqCt); + if (contentLength is null or > LoggingOptions.RequestResponseBodyMaxBytes) + { + return (redactedHeaders, LogFormatting.Omitted( + "form-large-or-unknown", + contentLength, + normalizedContentType, + LoggingOptions.RequestResponseBodyMaxBytes)); + } + + var form = await request.ReadFormAsync(context.RequestAborted); + return (redactedHeaders, RedactionHelper.RedactFormFields(form)); } - else + + // Text-like content within size limits + if (!MediaTypeUtil.IsTextLike(normalizedContentType) || + contentLength is not <= LoggingOptions.RequestResponseBodyMaxBytes) { - reqBody = LogFormatting.Omitted("non-text-or-large", - reqLen, - normReqCt, - LoggingOptions.RequestResponseBodyMaxBytes); + return (redactedHeaders, LogFormatting.Omitted( + "non-text-or-large", + contentLength, + normalizedContentType, + LoggingOptions.RequestResponseBodyMaxBytes)); } - var originalBody = context.Response.Body; - var tee = new CappedResponseBodyStream(originalBody, LoggingOptions.RequestResponseBodyMaxBytes); - context.Response.Body = tee; + request.EnableBuffering(); + return await HttpLogHelper.CaptureAsync( + request.Body, + request.Headers, + normalizedContentType, + ct); - var sw = Stopwatch.GetTimestamp(); - try - { - await next(context); - } - finally - { - var elapsed = Stopwatch.GetElapsedTime(sw) - .TotalMilliseconds; + // Non-text or large content + } - var resCt = MediaTypeUtil.Normalize(context.Response.ContentType); - var isText = HttpLogHelper.IsTextLike(resCt); + private static async Task<(object Headers, object Body)> CaptureResponseAsync(HttpContext context, + CappedResponseBodyStream tee, + CancellationToken ct = default) + { + var responseContentType = MediaTypeUtil.Normalize(context.Response.ContentType); + var isText = MediaTypeUtil.IsTextLike(responseContentType); + var redactedHeaders = RedactionHelper.RedactHeaders(context.Response.Headers); - object resHeaders = RedactionHelper.RedactHeaders(context.Response.Headers); - object resBody; + // Empty response + if (tee.TotalWritten == 0) + return (redactedHeaders, new Dictionary()); - if (tee.TotalWritten == 0) - { - resBody = new Dictionary(); - } - else if (isText && tee.TotalWritten <= LoggingOptions.RequestResponseBodyMaxBytes) - { - using var ms = new MemoryStream(tee.Captured.ToArray()); - (resHeaders, resBody) = await HttpLogHelper.CaptureAsync( - ms, - context.Response.Headers, - resCt); - } - else - { - var reason = isText ? "exceeds-limit" : "non-text"; - resBody = LogFormatting.Omitted(reason, - tee.TotalWritten, - resCt, - LoggingOptions.RequestResponseBodyMaxBytes); - } + // Text response within size limits + if (isText && tee.TotalWritten <= LoggingOptions.RequestResponseBodyMaxBytes) + { + using var memoryStream = new MemoryStream(tee.Captured.ToArray()); + return await HttpLogHelper.CaptureAsync( + memoryStream, + context.Response.Headers, + responseContentType, + ct); + } - context.Response.Body = originalBody; + // Non-text or large response + var reason = isText ? "exceeds-limit" : "non-text"; + return (redactedHeaders, LogFormatting.Omitted( + reason, + tee.TotalWritten, + responseContentType, + LoggingOptions.RequestResponseBodyMaxBytes)); + } - var scope = new Dictionary - { - ["RequestHeaders"] = reqHeaders, - ["RequestBody"] = reqBody, - ["ResponseHeaders"] = resHeaders, - ["ResponseBody"] = resBody, - ["ElapsedMs"] = elapsed, - ["Kind"] = "HttpIn" - }; - - if (context.Request.QueryString.HasValue) - { - scope["Query"] = context.Request.QueryString.Value; - } + private void LogHttpIn(HttpContext context, + object reqHeaders, + object reqBody, + object resHeaders, + object resBody, + double elapsedMs) + { + // Convert bodies to JSON strings to prevent Elasticsearch field explosion + var scope = new Dictionary + { + ["RequestHeaders"] = LogFormatting.ToJsonString(reqHeaders), + ["RequestBody"] = LogFormatting.ToJsonString(reqBody), + ["ResponseHeaders"] = LogFormatting.ToJsonString(resHeaders), + ["ResponseBody"] = LogFormatting.ToJsonString(resBody), + ["ElapsedMs"] = elapsedMs, + ["Kind"] = "HttpIn" + }; - using (logger.BeginScope(scope)) - { - logger.LogInformation( - "[HTTP IN] {Method} {Path} -> {StatusCode} in {ElapsedMilliseconds}ms", - context.Request.Method, - context.Request.Path.Value, - context.Response.StatusCode, - elapsed); - } + if (context.Request.QueryString.HasValue) + scope["Query"] = context.Request.QueryString.Value; + + using (logger.BeginScope(scope)) + { + logger.LogInformation( + "[HTTP IN] {Method} {Path} -> {StatusCode} in {ElapsedMilliseconds}ms", + context.Request.Method, + context.Request.Path.Value, + context.Response.StatusCode, + elapsedMs); } } } \ No newline at end of file diff --git a/src/SharedKernel/Logging/Middleware/SignalRLoggingHubFilter.cs b/src/SharedKernel/Logging/Middleware/SignalRLoggingHubFilter.cs index 85964b6..7f414b7 100644 --- a/src/SharedKernel/Logging/Middleware/SignalRLoggingHubFilter.cs +++ b/src/SharedKernel/Logging/Middleware/SignalRLoggingHubFilter.cs @@ -7,99 +7,96 @@ namespace SharedKernel.Logging.Middleware; internal sealed class SignalRLoggingHubFilter(ILogger logger) : IHubFilter { - public async ValueTask InvokeMethodAsync(HubInvocationContext invocationContext, - Func> next) - { - var start = Stopwatch.GetTimestamp(); + public async ValueTask InvokeMethodAsync( + HubInvocationContext invocationContext, + Func> next) + { + var timestamp = Stopwatch.GetTimestamp(); - // capture context - var hubName = invocationContext.Hub.GetType() - .Name; - var connId = invocationContext.Context.ConnectionId; - var userId = invocationContext.Context.UserIdentifier; - var methodName = invocationContext.HubMethodName; + var hubName = invocationContext.Hub.GetType().Name; + var connectionId = invocationContext.Context.ConnectionId; + var userId = invocationContext.Context.UserIdentifier; + var methodName = invocationContext.HubMethodName; - // serialize + redact - var rawArgsJson = JsonSerializer.Serialize(invocationContext.HubMethodArguments); - var redactedArgsObj = RedactionHelper.RedactBody("application/json", rawArgsJson); + // Serialize and redact arguments + var rawArgsJson = JsonSerializer.Serialize(invocationContext.HubMethodArguments); + var redactedArgs = RedactionHelper.RedactBody("application/json", rawArgsJson); - // invoke the actual method - var result = await next(invocationContext); + var result = await next(invocationContext); - var elapsedMs = Stopwatch.GetElapsedTime(start) - .TotalMilliseconds; + var elapsedMs = Stopwatch.GetElapsedTime(timestamp).TotalMilliseconds; - var scope = new Dictionary - { - ["Args"] = redactedArgsObj, - ["Hub"] = hubName, - ["ConnId"] = connId, - ["UserId"] = userId, - ["ElapsedMs"] = elapsedMs, - ["Kind"] = "SignalR" - }; + // Convert to JSON string to prevent Elasticsearch field explosion + var scope = new Dictionary + { + ["Args"] = LogFormatting.ToJsonString(redactedArgs), + ["Hub"] = hubName, + ["ConnId"] = connectionId, + ["UserId"] = userId, + ["ElapsedMs"] = elapsedMs, + ["Kind"] = "SignalR" + }; - using (logger.BeginScope(scope)) - { - logger.LogInformation( - "[SignalR] {Hub}.{Method} completed in {ElapsedMilliseconds}ms", - hubName, - methodName, - elapsedMs - ); - } + using (logger.BeginScope(scope)) + { + logger.LogInformation( + "[SignalR] {Hub}.{Method} completed in {ElapsedMilliseconds}ms", + hubName, + methodName, + elapsedMs); + } - return result; - } + return result; + } - public async Task OnConnectedAsync(HubLifetimeContext context, - Func next) - { - var hubName = context.Hub.GetType() - .Name; - var connectionId = context.Context.ConnectionId; - var userId = context.Context.UserIdentifier; + public async Task OnConnectedAsync(HubLifetimeContext context, Func next) + { + var hubName = context.Hub.GetType().Name; + var connectionId = context.Context.ConnectionId; + var userId = context.Context.UserIdentifier; - using (logger.BeginScope(new - { - Hub = hubName, - ConnId = connectionId, - UserId = userId - })) - { - logger.LogInformation("[Connected] SignalR {Hub}, ConnId={ConnId}, UserId={UserId} connected.", - hubName, - connectionId, - userId); - } + using (logger.BeginScope(new Dictionary + { + ["Hub"] = hubName, + ["ConnId"] = connectionId, + ["UserId"] = userId, + ["Kind"] = "SignalR" + })) + { + logger.LogInformation( + "[Connected] SignalR {Hub}, ConnId={ConnId}, UserId={UserId} connected.", + hubName, + connectionId, + userId); + } - await next(context); - } + await next(context); + } - public async Task OnDisconnectedAsync(HubLifetimeContext context, - Exception? exception, - Func next) - { - var hubName = context.Hub.GetType() - .Name; - var connectionId = context.Context.ConnectionId; - var userId = context.Context.UserIdentifier; + public async Task OnDisconnectedAsync( + HubLifetimeContext context, + Exception? exception, + Func next) + { + var hubName = context.Hub.GetType().Name; + var connectionId = context.Context.ConnectionId; + var userId = context.Context.UserIdentifier; - using (logger.BeginScope(new - { - Hub = hubName, - ConnId = connectionId, - UserId = userId - })) - { - logger.LogInformation( - "[Disconnected] SignalR {Hub}, ConnId={ConnId}, UserId={UserId} disconnected gracefully.", - hubName, - connectionId, - userId - ); - } + using (logger.BeginScope(new Dictionary + { + ["Hub"] = hubName, + ["ConnId"] = connectionId, + ["UserId"] = userId, + ["Kind"] = "SignalR" + })) + { + logger.LogInformation( + "[Disconnected] SignalR {Hub}, ConnId={ConnId}, UserId={UserId} disconnected gracefully.", + hubName, + connectionId, + userId); + } - await next(context, exception); - } + await next(context, exception); + } } \ No newline at end of file diff --git a/src/SharedKernel/Maintenance/MaintenanceMiddleware.cs b/src/SharedKernel/Maintenance/MaintenanceMiddleware.cs index 7e22470..9ac88c6 100644 --- a/src/SharedKernel/Maintenance/MaintenanceMiddleware.cs +++ b/src/SharedKernel/Maintenance/MaintenanceMiddleware.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; -using System.Threading; namespace SharedKernel.Maintenance; @@ -39,7 +38,7 @@ public async Task InvokeAsync(HttpContext httpContext) await next(httpContext); } - private static async Task Set503Async(HttpContext ctx) + private static async Task Set503Async(HttpContext ctx, CancellationToken ct = default) { ctx.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; ctx.Response.Headers.RetryAfter = "60"; @@ -52,7 +51,7 @@ private static async Task Set503Async(HttpContext ctx) { message = "The service is under maintenance. Please try again later." }); - await ctx.Response.WriteAsync(payload); + await ctx.Response.WriteAsync(payload, cancellationToken: ct); } } } \ No newline at end of file diff --git a/src/SharedKernel/OpenApi/UiAssets/panda-style.css b/src/SharedKernel/OpenApi/UiAssets/panda-style.css index 872367b..f20f5fa 100644 --- a/src/SharedKernel/OpenApi/UiAssets/panda-style.css +++ b/src/SharedKernel/OpenApi/UiAssets/panda-style.css @@ -1,4 +1,6 @@ -/* Glass topbar (light + dark via CSS vars) */ +/* ========================================================================== + CSS Variables - Theme colors for light/dark mode + ========================================================================== */ :root { --sw-topbar-bg: rgba(255, 255, 255, 0.65); --sw-topbar-border: rgba(0, 0, 0, 0.10); @@ -10,161 +12,95 @@ html.dark-mode { --sw-topbar-bg: rgba(18, 18, 18, 0.55); --sw-topbar-border: rgba(255, 255, 255, 0.14); --sw-topbar-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); + --opblock-hover-alpha: 0.22; } +/* ========================================================================== + Topbar - Glass effect styling + ========================================================================== */ .swagger-ui .topbar { background: var(--sw-topbar-bg) !important; backdrop-filter: blur(var(--sw-topbar-blur)) saturate(1.2); -webkit-backdrop-filter: blur(var(--sw-topbar-blur)) saturate(1.2); border-bottom: 1px solid var(--sw-topbar-border); box-shadow: var(--sw-topbar-shadow); - position: sticky; top: 0; z-index: 1000; } -/*.swagger-ui .auth-container .btn.modal-btn.auth {*/ -/* display: flex;*/ -/* justify-content: center;*/ -/*}*/ - -/*.topbar-wrapper > a > svg {*/ -/* opacity: 0;*/ -/*}*/ - - -/*.swagger-ui .topbar .download-url-wrapper .select-label select {*/ -/* border-color: #4E37D3;*/ -/*}*/ - -/*.swagger-ui .topbar .download-url-wrapper .select-label span {*/ -/* color: transparent;*/ -/* user-select: none;*/ -/*}*/ - - -/*.swagger-ui .topbar-wrapper img {*/ -/* visibility: hidden;*/ -/*}*/ - -/*element.style {*/ -/*}*/ - -/*.swagger-ui .auth-btn-wrapper {*/ -/* gap: 20px;*/ -/* margin-top: 20px;*/ -/*}*/ - -/*.swagger-ui .auth-wrapper .authorize {*/ -/* margin: 0;*/ -/*}*/ - -/*.swagger-ui .btn.authorize.locked, .swagger-ui .btn.authorize.unlocked {*/ -/* display: flex;*/ -/* align-items: center;*/ -/* justify-content: center;*/ -/* gap: 7px*/ - -/*}*/ - -/*.swagger-ui .btn.authorize span {*/ -/* clear: both;*/ -/* padding: 0;*/ -/* margin-top: 3px;*/ -/*}*/ - -/*.swagger-ui .auth-container .btn.modal-btn.auth {*/ -/* min-width: 250px;*/ -/* padding: 10px 0;*/ -/*}*/ - -/*.swagger-ui .topbar-wrapper .link::before {*/ -/* content: "";*/ -/* background: url("/swagger-resources/logo.svg") no-repeat center;*/ -/* background-size: contain;*/ -/* width: 36.402px;*/ -/* height: 28.8px;*/ -/* display: inline-block;*/ -/* position: relative;*/ -/*}*/ - -/*!* Add your second image *!*/ -/*.swagger-ui .topbar-wrapper .link::after {*/ -/* content: "";*/ -/* background: url("/swagger-resources/logo-wording.svg") no-repeat center;*/ -/* background-size: contain;*/ -/* width: 90.0276px;*/ -/* height: 20.2044px;*/ -/* display: inline-block;*/ -/* position: relative;*/ -/* left: -132px;*/ -/*}*/ - -/*div.topbar {*/ -/* background: transparent !important;*/ -/* backdrop-filter: blur(3px);*/ -/* position: fixed;*/ -/* width: 100%;*/ -/* top: 0;*/ -/* border-bottom: 1px solid #ffffff;*/ -/* z-index: 1000;*/ -/*}*/ - -/*.swagger-ui {*/ -/* margin-top: 128px;*/ -/*}*/ - -/*.swagger-ui .btn.authorize.unlocked {*/ -/* background-color: white !important;*/ -/* color: black !important;*/ -/* border: 2px solid #4E37D3 !important;*/ -/*}*/ - -/*.swagger-ui .btn.authorize.unlocked svg {*/ -/* fill: black !important;*/ -/*}*/ - -/*.swagger-ui .btn.authorize.locked {*/ -/* background-color: #4E37D3 !important;*/ -/* color: #ffffff !important;*/ -/* border: 2px solid #4E37D3 !important;*/ -/*}*/ - -/*.swagger-ui .btn.authorize.locked svg {*/ -/* fill: #ffffff !important;*/ -/*}*/ - -/*.swagger-ui .auth-container .btn.modal-btn.auth {*/ -/* background-color: #4E37D3 !important;*/ -/* color: #ffffff !important;*/ -/* border: 1px solid #4E37D3 !important;*/ -/* width: 100%;*/ -/*}*/ - -/*.btn.modal-btn.auth.btn-done.button {*/ -/* display: none;*/ -/*}*/ - -/*.auth-container {*/ -/* padding: 0 !important;*/ -/*}*/ - -/*.swagger-ui .dialog-ux .modal-ux {*/ -/* max-width: 600px;*/ -/*}*/ - -/*.opblock-summary-get:hover {*/ -/* background-color: #b3e0ff !important;*/ -/*}*/ - -/*.swagger-ui .auth-container input[type=text] {*/ -/* width: 100%;*/ -/*}*/ - - -/* colored hover effects for different HTTP methods */ +/* ========================================================================== + Custom Logo - Replace Swagger logo with Pandatech branding + ========================================================================== */ +.swagger-ui .topbar-wrapper .link > svg { + display: none !important; +} + +.swagger-ui .topbar-wrapper img { + visibility: hidden; +} +.swagger-ui .topbar-wrapper .link::before { + content: ""; + background: url("/swagger-resources/logo.svg") no-repeat center; + background-size: contain; + width: 36px; + height: 29px; + display: inline-block; +} + +.swagger-ui .topbar-wrapper .link::after { + content: ""; + background: url("/swagger-resources/logo-wording.svg") no-repeat center; + background-size: contain; + width: 90px; + height: 20px; + display: inline-block; + margin-left: 8px; +} + +html.dark-mode .swagger-ui .topbar-wrapper .link::after { + filter: brightness(0) invert(1); +} + +/* ========================================================================== + Topbar Controls - Light mode visibility fixes + ========================================================================== */ +html:not(.dark-mode) .swagger-ui .topbar .download-url-wrapper .select-label span { + color: #3b4151 !important; +} + +html:not(.dark-mode) .swagger-ui .topbar .download-url-wrapper select { + color: #3b4151 !important; + border-color: rgba(0, 0, 0, 0.2) !important; +} + +html:not(.dark-mode) .swagger-ui .topbar .dark-mode-toggle button svg { + fill: #3b4151 !important; +} + +/* ========================================================================== + Auth Modal - Layout and sizing improvements + ========================================================================== */ +.swagger-ui .dialog-ux .modal-ux { + overflow-y: auto; + max-height: 80vh; + max-width: 650px; +} + +.swagger-ui .dialog-ux .modal-ux-content { + scroll-behavior: auto; +} + +.swagger-ui .auth-container input[type=text], +.swagger-ui .auth-container input[type=password] { + width: 100% !important; + min-width: 300px; +} + +/* ========================================================================== + HTTP Method Hover Effects - Light mode + ========================================================================== */ .swagger-ui .opblock-summary-get:hover { background-color: #9dc9ff !important; } @@ -185,11 +121,9 @@ html.dark-mode { background-color: #faa3a3 !important; } -/* dark mode (Swagger UI 5.31+): softer hover tints */ -html.dark-mode { - --opblock-hover-alpha: .22; -} - +/* ========================================================================== + HTTP Method Hover Effects - Dark mode (softer tints) + ========================================================================== */ html.dark-mode .swagger-ui .opblock-summary-get:hover { background-color: rgb(157 201 255 / var(--opblock-hover-alpha)) !important; } diff --git a/src/SharedKernel/OpenApi/UiAssets/panda-style.js b/src/SharedKernel/OpenApi/UiAssets/panda-style.js index 9a35c99..b47ecd0 100644 --- a/src/SharedKernel/OpenApi/UiAssets/panda-style.js +++ b/src/SharedKernel/OpenApi/UiAssets/panda-style.js @@ -1,8 +1,8 @@ document.addEventListener('DOMContentLoaded', function () { - + // Set custom favicon const faviconPath = "/swagger-resources/favicon.svg"; - const existingLink = document.querySelector("link[rel*='icon']"); + if (existingLink) { existingLink.href = faviconPath; } else { @@ -12,4 +12,21 @@ document.addEventListener('DOMContentLoaded', function () { newLink.href = faviconPath; document.head.appendChild(newLink); } -}); + + // Fix auth modal scroll position - ensures modal opens at top + const observer = new MutationObserver(function (mutations) { + mutations.forEach(function (mutation) { + mutation.addedNodes.forEach(function (node) { + if (node.nodeType === 1) { + const modalContent = node.querySelector?.('.modal-ux-content') + || (node.classList?.contains('modal-ux-content') ? node : null); + if (modalContent) { + modalContent.scrollTop = 0; + } + } + }); + }); + }); + + observer.observe(document.body, { childList: true, subtree: true }); +}); \ No newline at end of file diff --git a/src/SharedKernel/OpenApi/UiExtensions.cs b/src/SharedKernel/OpenApi/UiExtensions.cs index f825579..06c1f0d 100644 --- a/src/SharedKernel/OpenApi/UiExtensions.cs +++ b/src/SharedKernel/OpenApi/UiExtensions.cs @@ -7,51 +7,54 @@ namespace SharedKernel.OpenApi; internal static class UiExtensions { - internal static WebApplication MapSwaggerUi(this WebApplication app, OpenApiConfig openApiConfigConfiguration) + extension(WebApplication app) { - app.UseSwaggerUI(options => + internal WebApplication MapSwaggerUi(OpenApiConfig openApiConfigConfiguration) { - foreach (var document in openApiConfigConfiguration.Documents) + app.UseSwaggerUI(options => { - options.SwaggerEndpoint($"{document.GetEndpointUrl()}", document.Title); - } + foreach (var document in openApiConfigConfiguration.Documents) + { + options.SwaggerEndpoint($"{document.GetEndpointUrl()}", document.Title); + } - options.RoutePrefix = "swagger"; - options.AddPandaOptions(); - }); + options.RoutePrefix = "swagger"; + options.AddPandaOptions(); + }); - foreach (var document in openApiConfigConfiguration.Documents.Where(x => x.ForExternalUse)) - { - app.UseSwaggerUI(options => + foreach (var document in openApiConfigConfiguration.Documents.Where(x => x.ForExternalUse)) { - options.SwaggerEndpoint($"{document.GetEndpointUrl()}", document.Title); - options.RoutePrefix = $"swagger/{document.GroupName}"; - options.AddPandaOptions(); - }); - } + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint($"{document.GetEndpointUrl()}", document.Title); + options.RoutePrefix = $"swagger/{document.GroupName}"; + options.AddPandaOptions(); + }); + } - return app; - } + return app; + } - internal static WebApplication MapScalarUi(this WebApplication app, OpenApiConfig openApiConfigConfiguration) - { - app.MapScalarApiReference(options => + internal WebApplication MapScalarUi(OpenApiConfig openApiConfigConfiguration) { - options.Theme = ScalarTheme.Kepler; - options.Favicon = "/swagger-resources/favicon.svg"; - options.SortTagsAlphabetically(); - - foreach (var document in openApiConfigConfiguration.Documents) + app.MapScalarApiReference(options => { - options.AddDocument( - document.GroupName, - document.Title, - document.GetEndpointUrl() - ); - } - }); - return app; + options.Theme = ScalarTheme.Kepler; + options.Favicon = "/swagger-resources/favicon.svg"; + options.SortTagsAlphabetically(); + + foreach (var document in openApiConfigConfiguration.Documents) + { + options.AddDocument( + document.GroupName, + document.Title, + document.GetEndpointUrl() + ); + } + }); + return app; + } } private static string GetEndpointUrl(this Document document) diff --git a/src/SharedKernel/SharedKernel.csproj b/src/SharedKernel/SharedKernel.csproj index 93c4a88..2f2a48b 100644 --- a/src/SharedKernel/SharedKernel.csproj +++ b/src/SharedKernel/SharedKernel.csproj @@ -8,13 +8,13 @@ Readme.md Pandatech MIT - 2.0.0 + 2.1.0 Pandatech.SharedKernel Pandatech Shared Kernel Library Pandatech, shared kernel, library, OpenAPI, Swagger, utilities, scalar Pandatech.SharedKernel provides centralized configurations, utilities, and extensions for ASP.NET Core projects. For more information refere to readme.md document. https://github.com/PandaTechAM/be-lib-sharedkernel - dotnet 10 upgrade + request/response logging enhancement + url encoded support + nuget updates and small perf boosts @@ -42,37 +42,37 @@ - - + + - - - - - - + + + + + + - - + + - - - - - - - - - - - - + + + + + + + + + + + + - - + + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithResponse.cs b/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithResponse.cs index fb0de6d..0c3ff61 100644 --- a/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithResponse.cs +++ b/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithResponse.cs @@ -1,6 +1,5 @@ using FluentValidation; using MediatR; -using ResponseCrafter.HttpExceptions; namespace SharedKernel.ValidatorAndMediatR.Behaviors; diff --git a/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithoutResponse.cs b/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithoutResponse.cs index d7a6547..b6c3099 100644 --- a/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithoutResponse.cs +++ b/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithoutResponse.cs @@ -1,6 +1,5 @@ using FluentValidation; using MediatR; -using ResponseCrafter.HttpExceptions; namespace SharedKernel.ValidatorAndMediatR.Behaviors;