< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Models.KestrunResponse
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Models/KestrunResponse.cs
Tag: Kestrun/Kestrun@0d738bf294e6281b936d031e1979d928007495ff
Line coverage
84%
Covered lines: 438
Uncovered lines: 83
Coverable lines: 521
Total lines: 1449
Line coverage: 84%
Branch coverage
68%
Covered branches: 217
Total branches: 316
Branch coverage: 68.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 08/26/2025 - 14:53:17 Line coverage: 89.3% (404/452) Branch coverage: 73.8% (192/260) Total lines: 1231 Tag: Kestrun/Kestrun@78d1e497d8ba989d121b57aa39aa3c6b22de743109/13/2025 - 17:19:56 Line coverage: 83.4% (404/484) Branch coverage: 66.2% (192/290) Total lines: 1304 Tag: Kestrun/Kestrun@ea635f1ee1937c260a89d1a43a3c203cd8767c7b09/14/2025 - 21:23:16 Line coverage: 88.3% (409/463) Branch coverage: 72.9% (194/266) Total lines: 1279 Tag: Kestrun/Kestrun@c9d2f0b3dd164d7dc0dc2407a9f006293d92422309/16/2025 - 16:28:42 Line coverage: 88.1% (410/465) Branch coverage: 71.6% (195/272) Total lines: 1282 Tag: Kestrun/Kestrun@d5c0d6132e97ca542441289c02a4c9e9d0364d4910/13/2025 - 16:52:37 Line coverage: 86.5% (425/491) Branch coverage: 71% (213/300) Total lines: 1347 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e12/12/2025 - 17:27:19 Line coverage: 84.7% (433/511) Branch coverage: 68.7% (216/314) Total lines: 1423 Tag: Kestrun/Kestrun@826bf9dcf9db118c5de4c78a3259bce9549f0dcd12/18/2025 - 21:41:58 Line coverage: 84% (438/521) Branch coverage: 68.6% (217/316) Total lines: 1449 Tag: Kestrun/Kestrun@0d738bf294e6281b936d031e1979d928007495ff 08/26/2025 - 14:53:17 Line coverage: 89.3% (404/452) Branch coverage: 73.8% (192/260) Total lines: 1231 Tag: Kestrun/Kestrun@78d1e497d8ba989d121b57aa39aa3c6b22de743109/13/2025 - 17:19:56 Line coverage: 83.4% (404/484) Branch coverage: 66.2% (192/290) Total lines: 1304 Tag: Kestrun/Kestrun@ea635f1ee1937c260a89d1a43a3c203cd8767c7b09/14/2025 - 21:23:16 Line coverage: 88.3% (409/463) Branch coverage: 72.9% (194/266) Total lines: 1279 Tag: Kestrun/Kestrun@c9d2f0b3dd164d7dc0dc2407a9f006293d92422309/16/2025 - 16:28:42 Line coverage: 88.1% (410/465) Branch coverage: 71.6% (195/272) Total lines: 1282 Tag: Kestrun/Kestrun@d5c0d6132e97ca542441289c02a4c9e9d0364d4910/13/2025 - 16:52:37 Line coverage: 86.5% (425/491) Branch coverage: 71% (213/300) Total lines: 1347 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e12/12/2025 - 17:27:19 Line coverage: 84.7% (433/511) Branch coverage: 68.7% (216/314) Total lines: 1423 Tag: Kestrun/Kestrun@826bf9dcf9db118c5de4c78a3259bce9549f0dcd12/18/2025 - 21:41:58 Line coverage: 84% (438/521) Branch coverage: 68.6% (217/316) Total lines: 1449 Tag: Kestrun/Kestrun@0d738bf294e6281b936d031e1979d928007495ff

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)75%44100%
.cctor()100%11100%
get_Context()100%210%
get_StatusCode()100%11100%
get_Headers()100%11100%
get_ContentType()100%11100%
get_Body()100%11100%
get_RedirectUrl()100%11100%
get_Cookies()100%11100%
get_Encoding()100%11100%
get_ContentDisposition()100%11100%
get_Request()100%11100%
get_AcceptCharset()100%11100%
get_BodyAsyncThreshold()100%11100%
get_CacheControl()100%11100%
GetSafeCurrentDirectoryOrBaseDirectory()100%1128.57%
GetSafeCurrentDirectoryForLogging()100%11100%
GetHeader(...)0%620%
DetermineContentType(...)25%491236.36%
IsTextBasedContentType(...)93.75%161692.85%
WriteFileResponse(...)75%161696.55%
WriteJsonResponse(...)100%11100%
WriteJsonResponseAsync()100%11100%
WriteJsonResponse(...)100%210%
WriteJsonResponseAsync()100%44100%
WriteJsonResponse(...)100%11100%
WriteJsonResponseAsync()100%44100%
WriteCborResponseAsync()75%44100%
WriteCborResponse(...)100%210%
WriteBsonResponseAsync()75%44100%
WriteBsonResponse(...)100%210%
WriteResponseAsync()18.75%3003236%
WriteResponse(...)100%210%
WriteCsvResponse(...)100%88100%
WriteCsvResponseAsync()75%44100%
WriteYamlResponse(...)100%11100%
WriteYamlResponseAsync()75%44100%
WriteXmlResponse(...)100%11100%
WriteXmlResponseAsync()87.5%88100%
WriteTextResponse(...)100%11100%
WriteTextResponseAsync()83.33%6685.71%
WriteFormUrlEncodedResponse(...)100%11100%
WriteFormUrlEncodedResponseAsync()100%22100%
WriteRedirectResponse(...)83.33%6692.3%
WriteBinaryResponse(...)75%44100%
WriteStreamResponse(...)100%22100%
get_Error()100%11100%
get_Details()100%11100%
get_Exception()100%11100%
get_StackTrace()100%11100%
get_Status()100%11100%
get_Reason()100%11100%
get_Timestamp()100%11100%
get_Path()100%11100%
get_Method()100%11100%
WriteErrorResponseAsync()62.5%8895%
WriteErrorResponse(...)100%11100%
WriteErrorResponseAsync()75%88100%
WriteErrorResponse(...)100%11100%
WriteFormattedErrorResponseAsync()100%2020100%
RenderInlineTemplate(...)64.28%151481.81%
RenderInline(...)85.71%141493.75%
TryResolveValue(...)56.25%271665.21%
RevalidateCache(...)100%210%
WriteHtmlResponseAsync()66.66%6683.33%
WriteHtmlResponseAsync()100%210%
WriteHtmlResponse(...)100%210%
WriteHtmlResponseFromFileAsync()75%4475%
WriteHtmlResponse(...)100%210%
WriteHtmlResponseFromFile(...)100%210%
WriteStatusOnly(...)100%210%
ApplyTo()81.25%191677.77%
EnsureContentType(...)100%88100%
EnsureStatus(...)100%22100%
ApplyCachingHeaders(...)50%2266.66%
ApplyContentDispositionHeader(...)78.57%141488.88%
ApplyHeadersAndCookies(...)50%13857.14%
WriteBodyAsync()59.09%242285.36%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Models/KestrunResponse.cs

#LineLine coverage
 1
 2using System.Xml.Linq;
 3using Newtonsoft.Json;
 4using Newtonsoft.Json.Serialization;
 5using Microsoft.AspNetCore.StaticFiles;
 6using System.Text;
 7using Serilog;
 8using Serilog.Events;
 9using System.Buffers;
 10using Microsoft.Extensions.FileProviders;
 11using Microsoft.AspNetCore.WebUtilities;
 12using System.Net;
 13using MongoDB.Bson;
 14using Kestrun.Utilities;
 15using System.Collections;
 16using CsvHelper.Configuration;
 17using System.Globalization;
 18using CsvHelper;
 19using System.Reflection;
 20using Microsoft.Net.Http.Headers;
 21using Kestrun.Utilities.Yaml;
 22using Kestrun.Hosting.Options;
 23
 24namespace Kestrun.Models;
 25
 26/// <summary>
 27/// Represents an HTTP response in the Kestrun framework, providing methods to write various content types and manage he
 28/// </summary>
 29/// <remarks>
 30/// Initializes a new instance of the <see cref="KestrunResponse"/> class with the specified request and optional body a
 31/// </remarks>
 32/// <param name="request">The associated <see cref="KestrunRequest"/> for this response.</param>
 33/// <param name="bodyAsyncThreshold">The threshold in bytes for using async body write operations. Defaults to 8192.</pa
 10834public class KestrunResponse(KestrunRequest request, int bodyAsyncThreshold = 8192)
 35{
 36    /// <summary>
 37    /// A set of MIME types that are considered text-based for response content.
 38    /// </summary>
 139    public static readonly HashSet<string> TextBasedMimeTypes =
 140    new(StringComparer.OrdinalIgnoreCase)
 141    {
 142        "application/json",
 143        "application/xml",
 144        "application/javascript",
 145        "application/xhtml+xml",
 146        "application/x-www-form-urlencoded",
 147        "application/yaml",
 148        "application/graphql"
 149    };
 50
 51    /// <summary>
 52    /// Gets the <see cref="HttpContext"/> associated with the response.
 53    /// </summary>
 054    public HttpContext Context => Request.HttpContext;
 55    /// <summary>
 56    /// Gets or sets the HTTP status code for the response.
 57    /// </summary>
 25058    public int StatusCode { get; set; } = request.HttpContext.Response.StatusCode;
 59    /// <summary>
 60    /// Gets or sets the collection of HTTP headers for the response.
 61    /// </summary>
 16862    public Dictionary<string, string> Headers { get; set; } = [];
 63    /// <summary>
 64    /// Gets or sets the MIME content type of the response.
 65    /// </summary>
 33966    public string? ContentType { get; set; } = "text/plain";
 67    /// <summary>
 68    /// Gets or sets the body of the response, which can be a string, byte array, stream, or file info.
 69    /// </summary>
 20070    public object? Body { get; set; }
 71    /// <summary>
 72    /// Gets or sets the URL to redirect the response to, if an HTTP redirect is required.
 73    /// </summary>
 5474    public string? RedirectUrl { get; set; } // For HTTP redirects
 75    /// <summary>
 76    /// Gets or sets the list of Set-Cookie header values for the response.
 77    /// </summary>
 2578    public List<string>? Cookies { get; set; } // For Set-Cookie headers
 79
 80    /// <summary>
 81    /// Text encoding for textual MIME types.
 82    /// </summary>
 14783    public Encoding Encoding { get; set; } = Encoding.UTF8;
 84
 85    /// <summary>
 86    /// Content-Disposition header value.
 87    /// </summary>
 14588    public ContentDispositionOptions ContentDisposition { get; set; } = new ContentDispositionOptions();
 89    /// <summary>
 90    /// Gets the associated KestrunRequest for this response.
 91    /// </summary>
 14692    public KestrunRequest Request { get; private set; } = request ?? throw new ArgumentNullException(nameof(request));
 93
 94    /// <summary>
 95    /// Global text encoding for all responses. Defaults to UTF-8.
 96    /// </summary>
 12997    public Encoding AcceptCharset { get; private set; } = request.Headers.TryGetValue("Accept-Charset", out var value) ?
 98
 99    /// <summary>
 100    /// If the response body is larger than this threshold (in bytes), async write will be used.
 101    /// </summary>
 108102    public int BodyAsyncThreshold { get; set; } = bodyAsyncThreshold;
 103
 104    /// <summary>
 105    /// Cache-Control header value for the response.
 106    /// </summary>
 25107    public CacheControlHeaderValue? CacheControl { get; set; }
 108
 109    #region Constructors
 110    #endregion
 111
 112    #region Helpers
 113    private static string GetSafeCurrentDirectoryOrBaseDirectory()
 114    {
 115        try
 116        {
 2117            return Directory.GetCurrentDirectory();
 118        }
 0119        catch (Exception ex) when (ex is IOException
 0120                                   or UnauthorizedAccessException
 0121                                   or DirectoryNotFoundException
 0122                                   or FileNotFoundException)
 123        {
 124            // On Unix/macOS, getcwd() can throw if the process CWD was deleted.
 125            // We use AppContext.BaseDirectory as a stable fallback to avoid crashing in diagnostics
 126            // and when resolving relative paths.
 0127            return AppContext.BaseDirectory;
 128        }
 2129    }
 130
 2131    private static string GetSafeCurrentDirectoryForLogging() => GetSafeCurrentDirectoryOrBaseDirectory();
 132
 133    /// <summary>
 134    /// Retrieves the value of the specified header from the response headers.
 135    /// </summary>
 136    /// <param name="key">The name of the header to retrieve.</param>
 137    /// <returns>The value of the header if found; otherwise, null.</returns>
 0138    public string? GetHeader(string key) => Headers.TryGetValue(key, out var value) ? value : null;
 139
 140    /// <summary>
 141    /// Determines the appropriate content type for the response based on the provided content type and default type.
 142    /// </summary>
 143    /// <param name="contentType">The initial content type to consider.</param>
 144    /// <param name="defaultType">The default content type to use if none is provided or found.</param>
 145    /// <returns>The determined content type to use for the response.</returns>
 146    private string DetermineContentType(string? contentType, string? defaultType = null)
 147    {
 1148        if (string.IsNullOrWhiteSpace(contentType))
 149        {
 1150            if (Request.Headers.TryGetValue("Accept", out var acceptHeader))
 151            {
 1152                contentType = acceptHeader.ToLowerInvariant();
 153            }
 154            else
 155            {
 0156                if (string.IsNullOrWhiteSpace(defaultType))
 157                {
 0158                    var dft = Context.GetEndpoint()?
 0159                    .Metadata
 0160                    .FirstOrDefault(m => m is DefaultResponseContentType)
 0161                    as DefaultResponseContentType;
 0162                    contentType = dft?.ContentType ?? "text/html";
 163                }
 164                else
 165                {
 0166                    contentType = defaultType;
 167                }
 168            }
 169        }
 170
 1171        return contentType;
 172    }
 173
 174    /// <summary>
 175    /// Determines whether the specified content type is text-based or supports a charset.
 176    /// </summary>
 177    /// <param name="type">The MIME content type to check.</param>
 178    /// <returns>True if the content type is text-based; otherwise, false.</returns>
 179    public static bool IsTextBasedContentType(string type)
 180    {
 31181        if (Log.IsEnabled(LogEventLevel.Debug))
 182        {
 30183            Log.Debug("Checking if content type is text-based: {ContentType}", type);
 184        }
 185
 186        // Check if the content type is text-based or has a charset
 31187        if (string.IsNullOrEmpty(type))
 188        {
 1189            return false;
 190        }
 191
 30192        if (type.StartsWith("text/", StringComparison.OrdinalIgnoreCase))
 193        {
 17194            return true;
 195        }
 13196        if (type == "application/x-www-form-urlencoded")
 197        {
 0198            return true;
 199        }
 200
 201        // Include structured types using XML or JSON suffixes
 13202        if (type.EndsWith("xml", StringComparison.OrdinalIgnoreCase) ||
 13203            type.EndsWith("json", StringComparison.OrdinalIgnoreCase) ||
 13204            type.EndsWith("yaml", StringComparison.OrdinalIgnoreCase) ||
 13205            type.EndsWith("csv", StringComparison.OrdinalIgnoreCase))
 206        {
 4207            return true;
 208        }
 209
 210        // Common application types where charset makes sense
 9211        return TextBasedMimeTypes.Contains(type);
 212    }
 213    #endregion
 214
 215    #region  Response Writers
 216    /// <summary>
 217    /// Writes a file response with the specified file path, content type, and HTTP status code.
 218    /// </summary>
 219    /// <param name="filePath">The path to the file to be sent in the response.</param>
 220    /// <param name="contentType">The MIME type of the file content.</param>
 221    /// <param name="statusCode">The HTTP status code for the response.</param>
 222    public void WriteFileResponse(
 223        string? filePath,
 224        string? contentType,
 225        int statusCode = StatusCodes.Status200OK
 226    )
 227    {
 2228        if (Log.IsEnabled(LogEventLevel.Debug))
 229        {
 2230            Log.Debug("Writing file response,FilePath={FilePath} StatusCode={StatusCode}, ContentType={ContentType}, Cur
 2231                filePath, statusCode, contentType, GetSafeCurrentDirectoryForLogging());
 232        }
 233
 2234        if (string.IsNullOrEmpty(filePath))
 235        {
 0236            throw new ArgumentException("File path cannot be null or empty.", nameof(filePath));
 237        }
 238
 239        // IMPORTANT:
 240        // - Path.GetFullPath(relative) uses the process CWD.
 241        // - If the CWD is missing/deleted (can occur in CI/test scenarios), GetFullPath can fail.
 242        // Resolve relative paths against a safe, existing base directory instead.
 2243        var fullPath = Path.IsPathRooted(filePath)
 2244            ? Path.GetFullPath(filePath)
 2245            : Path.GetFullPath(filePath, GetSafeCurrentDirectoryOrBaseDirectory());
 246
 2247        if (!File.Exists(fullPath))
 248        {
 1249            StatusCode = StatusCodes.Status404NotFound;
 1250            Body = $"File not found: {filePath}";
 1251            ContentType = $"text/plain; charset={Encoding.WebName}";
 1252            return;
 253        }
 254
 255        // 2. Extract the directory to use as the "root"
 1256        var directory = Path.GetDirectoryName(fullPath)
 1257                       ?? throw new InvalidOperationException("Could not determine directory from file path");
 258
 1259        if (Log.IsEnabled(LogEventLevel.Debug))
 260        {
 1261            Log.Debug("Serving file: {FilePath}", fullPath);
 262        }
 263
 264        // Create a physical file provider for the directory
 1265        var physicalProvider = new PhysicalFileProvider(directory);
 1266        var fi = physicalProvider.GetFileInfo(Path.GetFileName(fullPath));
 1267        var provider = new FileExtensionContentTypeProvider();
 1268        contentType ??= provider.TryGetContentType(fullPath, out var ct)
 1269                ? ct
 1270                : "application/octet-stream";
 1271        Body = fi;
 272
 273        // headers & metadata
 1274        StatusCode = statusCode;
 1275        ContentType = contentType;
 1276        Log.Debug("File response prepared: FileName={FileName}, Length={Length}, ContentType={ContentType}",
 1277            fi.Name, fi.Length, ContentType);
 1278    }
 279
 280    /// <summary>
 281    /// Writes a JSON response with the specified input object and HTTP status code.
 282    /// </summary>
 283    /// <param name="inputObject">The object to be converted to JSON.</param>
 284    /// <param name="statusCode">The HTTP status code for the response.</param>
 5285    public void WriteJsonResponse(object? inputObject, int statusCode = StatusCodes.Status200OK) => WriteJsonResponseAsy
 286
 287    /// <summary>
 288    /// Asynchronously writes a JSON response with the specified input object and HTTP status code.
 289    /// </summary>
 290    /// <param name="inputObject">The object to be converted to JSON.</param>
 291    /// <param name="statusCode">The HTTP status code for the response.</param>
 292    /// <param name="contentType">The MIME type of the response content.</param>
 4293    public async Task WriteJsonResponseAsync(object? inputObject, int statusCode = StatusCodes.Status200OK, string? cont
 294
 295    /// <summary>
 296    /// Writes a JSON response using the specified input object and serializer settings.
 297    /// </summary>
 298    /// <param name="inputObject">The object to be converted to JSON.</param>
 299    /// <param name="serializerSettings">The settings to use for JSON serialization.</param>
 300    /// <param name="statusCode">The HTTP status code for the response.</param>
 301    /// <param name="contentType">The MIME type of the response content.</param>
 0302    public void WriteJsonResponse(object? inputObject, JsonSerializerSettings serializerSettings, int statusCode = Statu
 303
 304    /// <summary>
 305    /// Asynchronously writes a JSON response using the specified input object and serializer settings.
 306    /// </summary>
 307    /// <param name="inputObject">The object to be converted to JSON.</param>
 308    /// <param name="serializerSettings">The settings to use for JSON serialization.</param>
 309    /// <param name="statusCode">The HTTP status code for the response.</param>
 310    /// <param name="contentType">The MIME type of the response content.</param>
 311    public async Task WriteJsonResponseAsync(object? inputObject, JsonSerializerSettings serializerSettings, int statusC
 312    {
 13313        if (Log.IsEnabled(LogEventLevel.Debug))
 314        {
 13315            Log.Debug("Writing JSON response (async), StatusCode={StatusCode}, ContentType={ContentType}", statusCode, c
 316        }
 317
 26318        Body = await Task.Run(() => JsonConvert.SerializeObject(inputObject, serializerSettings));
 13319        ContentType = string.IsNullOrEmpty(contentType) ? $"application/json; charset={Encoding.WebName}" : contentType;
 13320        StatusCode = statusCode;
 13321    }
 322    /// <summary>
 323    /// Writes a JSON response with the specified input object, serialization depth, compression option, status code, an
 324    /// </summary>
 325    /// <param name="inputObject">The object to be converted to JSON.</param>
 326    /// <param name="depth">The maximum depth for JSON serialization.</param>
 327    /// <param name="compress">Whether to compress the JSON output (no indentation).</param>
 328    /// <param name="statusCode">The HTTP status code for the response.</param>
 329    /// <param name="contentType">The MIME type of the response content.</param>
 1330    public void WriteJsonResponse(object? inputObject, int depth, bool compress, int statusCode = StatusCodes.Status200O
 331
 332    /// <summary>
 333    /// Asynchronously writes a JSON response with the specified input object, serialization depth, compression option, 
 334    /// </summary>
 335    /// <param name="inputObject">The object to be converted to JSON.</param>
 336    /// <param name="depth">The maximum depth for JSON serialization.</param>
 337    /// <param name="compress">Whether to compress the JSON output (no indentation).</param>
 338    /// <param name="statusCode">The HTTP status code for the response.</param>
 339    /// <param name="contentType">The MIME type of the response content.</param>
 340    public async Task WriteJsonResponseAsync(object? inputObject, int depth, bool compress, int statusCode = StatusCodes
 341    {
 13342        if (Log.IsEnabled(LogEventLevel.Debug))
 343        {
 13344            Log.Debug("Writing JSON response (async), StatusCode={StatusCode}, ContentType={ContentType}, Depth={Depth},
 13345                statusCode, contentType, depth, compress);
 346        }
 347
 13348        var serializerSettings = new JsonSerializerSettings
 13349        {
 13350            Formatting = compress ? Formatting.None : Formatting.Indented,
 13351            ContractResolver = new CamelCasePropertyNamesContractResolver(),
 13352            ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
 13353            NullValueHandling = NullValueHandling.Ignore,
 13354            DefaultValueHandling = DefaultValueHandling.Ignore,
 13355            MaxDepth = depth
 13356        };
 13357        await WriteJsonResponseAsync(inputObject, serializerSettings: serializerSettings, statusCode: statusCode, conten
 13358    }
 359    /// <summary>
 360    /// Writes a CBOR response (binary, efficient, not human-readable).
 361    /// </summary>
 362    public async Task WriteCborResponseAsync(object? inputObject, int statusCode = StatusCodes.Status200OK, string? cont
 363    {
 2364        if (Log.IsEnabled(LogEventLevel.Debug))
 365        {
 2366            Log.Debug("Writing CBOR response, StatusCode={StatusCode}, ContentType={ContentType}", statusCode, contentTy
 367        }
 368
 369        // Serialize to CBOR using PeterO.Cbor
 4370        Body = await Task.Run(() => inputObject != null
 4371            ? PeterO.Cbor.CBORObject.FromObject(inputObject).EncodeToBytes()
 4372            : []);
 2373        ContentType = string.IsNullOrEmpty(contentType) ? "application/cbor" : contentType;
 2374        StatusCode = statusCode;
 2375    }
 376
 377    /// <summary>
 378    /// Writes a CBOR response (binary, efficient, not human-readable).
 379    /// </summary>
 380    /// <param name="inputObject">The object to be converted to CBOR.</param>
 381    /// <param name="statusCode">The HTTP status code for the response.</param>
 382    /// <param name="contentType">The MIME type of the response content.</param>
 0383    public void WriteCborResponse(object? inputObject, int statusCode = StatusCodes.Status200OK, string? contentType = n
 384
 385    /// <summary>
 386    /// Asynchronously writes a BSON response with the specified input object, status code, and content type.
 387    /// </summary>
 388    /// <param name="inputObject">The object to be converted to BSON.</param>
 389    /// <param name="statusCode">The HTTP status code for the response.</param>
 390    /// <param name="contentType">The MIME type of the response content.</param>
 391    public async Task WriteBsonResponseAsync(object? inputObject, int statusCode = StatusCodes.Status200OK, string? cont
 392    {
 1393        if (Log.IsEnabled(LogEventLevel.Debug))
 394        {
 1395            Log.Debug("Writing BSON response, StatusCode={StatusCode}, ContentType={ContentType}", statusCode, contentTy
 396        }
 397
 398        // Serialize to BSON (as byte[])
 2399        Body = await Task.Run(() => inputObject != null ? inputObject.ToBson() : []);
 1400        ContentType = string.IsNullOrEmpty(contentType) ? "application/bson" : contentType;
 1401        StatusCode = statusCode;
 1402    }
 403
 404    /// <summary>
 405    /// Writes a BSON response with the specified input object, status code, and content type.
 406    /// </summary>
 407    /// <param name="inputObject">The object to be converted to BSON.</param>
 408    /// <param name="statusCode">The HTTP status code for the response.</param>
 409    /// <param name="contentType">The MIME type of the response content.</param>
 0410    public void WriteBsonResponse(object? inputObject, int statusCode = StatusCodes.Status200OK, string? contentType = n
 411
 412    /// <summary>
 413    /// Asynchronously writes a response with the specified input object and HTTP status code.
 414    /// Chooses the response format based on the Accept header or defaults to text/plain.
 415    /// </summary>
 416    /// <param name="inputObject">The object to be sent in the response body.</param>
 417    /// <param name="statusCode">The HTTP status code for the response.</param>
 418    public async Task WriteResponseAsync(object? inputObject, int statusCode = StatusCodes.Status200OK)
 419    {
 1420        if (Log.IsEnabled(LogEventLevel.Debug))
 421        {
 1422            Log.Debug("Writing response, StatusCode={StatusCode}", statusCode);
 423        }
 424
 1425        Body = inputObject;
 1426        ContentType = DetermineContentType(contentType: string.Empty); // Ensure ContentType is set based on Accept head
 1427        if (ContentType.Contains(','))
 428        {
 0429            var ContentTypes = ContentType.Split(','); // Take the first type only
 0430            ContentType = "application/json"; // fallback
 0431            foreach (var ct in ContentTypes)
 432            {
 0433                if (ct.Contains("json") || ct.Contains("xml") || ct.Contains("yaml") || ct.Contains("yml"))
 434                {
 0435                    if (Log.IsEnabled(LogEventLevel.Verbose))
 436                    {
 0437                        Log.Verbose("Multiple content types in Accept header, selecting {ContentType}", ct);
 438                    }
 0439                    ContentType = ct;
 0440                    break;
 441                }
 442            }
 443        }
 1444        if (Log.IsEnabled(LogEventLevel.Verbose))
 445        {
 0446            Log.Verbose("Determined ContentType={ContentType}", ContentType);
 447        }
 1448        if (ContentType.Contains("json"))
 449        {
 1450            await WriteJsonResponseAsync(inputObject: inputObject, statusCode: statusCode, contentType: ContentType);
 451        }
 0452        else if (ContentType.Contains("yaml") || ContentType.Contains("yml"))
 453        {
 0454            await WriteYamlResponseAsync(inputObject: inputObject, statusCode: statusCode, contentType: ContentType);
 455        }
 0456        else if (ContentType.Contains("xml"))
 457        {
 0458            await WriteXmlResponseAsync(inputObject: inputObject, statusCode: statusCode, contentType: ContentType);
 459        }
 0460        else if (ContentType.Contains("application/x-www-form-urlencoded"))
 461        {
 0462            await WriteFormUrlEncodedResponseAsync(inputObject: inputObject, statusCode: statusCode);
 463        }
 464        else
 465        {
 0466            await WriteTextResponseAsync(inputObject: inputObject?.ToString() ?? string.Empty, statusCode: statusCode);
 467        }
 1468    }
 469
 470    /// <summary>
 471    /// Writes a response with the specified input object and HTTP status code.
 472    /// Chooses the response format based on the Accept header or defaults to text/plain.
 473    /// </summary>
 474    /// <param name="inputObject">The object to be sent in the response body.</param>
 475    /// <param name="statusCode">The HTTP status code for the response.</param>
 0476    public void WriteResponse(object? inputObject, int statusCode = StatusCodes.Status200OK) => WriteResponseAsync(input
 477
 478    /// <summary>
 479    /// Writes a CSV response with the specified input object, status code, content type, and optional CsvConfiguration.
 480    /// </summary>
 481    /// <param name="inputObject">The object to be converted to CSV.</param>
 482    /// <param name="statusCode">The HTTP status code for the response.</param>
 483    /// <param name="contentType">The MIME type of the response content.</param>
 484    /// <param name="config">An optional CsvConfiguration to customize CSV output.</param>
 485    public void WriteCsvResponse(
 486            object? inputObject,
 487            int statusCode = StatusCodes.Status200OK,
 488            string? contentType = null,
 489            CsvConfiguration? config = null)
 490    {
 2491        Action<CsvConfiguration>? tweaker = null;
 492
 2493        if (config is not null)
 494        {
 1495            tweaker = target =>
 1496            {
 90497                foreach (var prop in typeof(CsvConfiguration)
 1498                     .GetProperties(BindingFlags.Public | BindingFlags.Instance))
 1499                {
 44500                    if (prop.CanRead && prop.CanWrite)
 1501                    {
 44502                        var value = prop.GetValue(config);
 44503                        prop.SetValue(target, value);
 1504                    }
 1505                }
 2506            };
 507        }
 2508        WriteCsvResponseAsync(inputObject, statusCode, contentType, tweaker).GetAwaiter().GetResult();
 2509    }
 510
 511    /// <summary>
 512    /// Asynchronously writes a CSV response with the specified input object, status code, content type, and optional co
 513    /// </summary>
 514    /// <param name="inputObject">The object to be converted to CSV.</param>
 515    /// <param name="statusCode">The HTTP status code for the response.</param>
 516    /// <param name="contentType">The MIME type of the response content.</param>
 517    /// <param name="tweak">An optional action to tweak the CsvConfiguration.</param>
 518    public async Task WriteCsvResponseAsync(
 519        object? inputObject,
 520        int statusCode = StatusCodes.Status200OK,
 521        string? contentType = null,
 522        Action<CsvConfiguration>? tweak = null)
 523    {
 3524        if (Log.IsEnabled(LogEventLevel.Debug))
 525        {
 3526            Log.Debug("Writing CSV response (async), StatusCode={StatusCode}, ContentType={ContentType}",
 3527                      statusCode, contentType);
 528        }
 529
 530        // Serialize inside a background task so heavy reflection never blocks the caller
 3531        Body = await Task.Run(() =>
 3532        {
 3533            var cfg = new CsvConfiguration(CultureInfo.InvariantCulture)
 3534            {
 3535                HasHeaderRecord = true,
 3536                NewLine = Environment.NewLine
 3537            };
 3538            tweak?.Invoke(cfg);                         // let the caller flirt with the config
 3539
 3540            using var sw = new StringWriter();
 3541            using var csv = new CsvWriter(sw, cfg);
 3542
 3543            // CsvHelper insists on an enumerable; wrap single objects so it stays happy
 3544            if (inputObject is IEnumerable records and not string)
 3545            {
 3546                csv.WriteRecords(records);              // whole collections (IEnumerable<T>)
 3547            }
 0548            else if (inputObject is not null)
 3549            {
 0550                csv.WriteRecords([inputObject]); // lone POCO
 3551            }
 3552            else
 3553            {
 0554                csv.WriteHeader<object>();              // nothing? write only headers for an empty file
 3555            }
 3556
 3557            return sw.ToString();
 6558        }).ConfigureAwait(false);
 559
 3560        ContentType = string.IsNullOrEmpty(contentType)
 3561            ? $"text/csv; charset={Encoding.WebName}"
 3562            : contentType;
 3563        StatusCode = statusCode;
 3564    }
 565    /// <summary>
 566    /// Writes a YAML response with the specified input object, status code, and content type.
 567    /// </summary>
 568    /// <param name="inputObject">The object to be converted to YAML.</param>
 569    /// <param name="statusCode">The HTTP status code for the response.</param>
 570    /// <param name="contentType">The MIME type of the response content.</param>
 1571    public void WriteYamlResponse(object? inputObject, int statusCode = StatusCodes.Status200OK, string? contentType = n
 572
 573    /// <summary>
 574    /// Asynchronously writes a YAML response with the specified input object, status code, and content type.
 575    /// </summary>
 576    /// <param name="inputObject">The object to be converted to YAML.</param>
 577    /// <param name="statusCode">The HTTP status code for the response.</param>
 578    /// <param name="contentType">The MIME type of the response content.</param>
 579    public async Task WriteYamlResponseAsync(object? inputObject, int statusCode = StatusCodes.Status200OK, string? cont
 580    {
 3581        if (Log.IsEnabled(LogEventLevel.Debug))
 582        {
 3583            Log.Debug("Writing YAML response (async), StatusCode={StatusCode}, ContentType={ContentType}", statusCode, c
 584        }
 585
 6586        Body = await Task.Run(() => YamlHelper.ToYaml(inputObject));
 3587        ContentType = string.IsNullOrEmpty(contentType) ? $"application/yaml; charset={Encoding.WebName}" : contentType;
 3588        StatusCode = statusCode;
 3589    }
 590
 591    /// <summary>
 592    /// Writes an XML response with the specified input object, status code, and content type.
 593    /// </summary>
 594    /// <param name="inputObject">The object to be converted to XML.</param>
 595    /// <param name="statusCode">The HTTP status code for the response.</param>
 596    /// <param name="contentType">The MIME type of the response content.</param>
 597    /// <param name="rootElementName">Optional custom XML root element name. Defaults to <c>Response</c>.</param>
 598    /// <param name="compress">If true, emits compact XML (no indentation); if false (default) output is human readable.
 599    public void WriteXmlResponse(object? inputObject, int statusCode = StatusCodes.Status200OK, string? contentType = nu
 6600        => WriteXmlResponseAsync(inputObject, statusCode, contentType, rootElementName, compress).GetAwaiter().GetResult
 601
 602    /// <summary>
 603    /// Asynchronously writes an XML response with the specified input object, status code, and content type.
 604    /// </summary>
 605    /// <param name="inputObject">The object to be converted to XML.</param>
 606    /// <param name="statusCode">The HTTP status code for the response.</param>
 607    /// <param name="contentType">The MIME type of the response content.</param>
 608    /// <param name="rootElementName">Optional custom XML root element name. Defaults to <c>Response</c>.</param>
 609    /// <param name="compress">If true, emits compact XML (no indentation); if false (default) output is human readable.
 610    public async Task WriteXmlResponseAsync(object? inputObject, int statusCode = StatusCodes.Status200OK, string? conte
 611    {
 8612        if (Log.IsEnabled(LogEventLevel.Debug))
 613        {
 8614            Log.Debug("Writing XML response (async), StatusCode={StatusCode}, ContentType={ContentType}", statusCode, co
 615        }
 616
 8617        var root = string.IsNullOrWhiteSpace(rootElementName) ? "Response" : rootElementName.Trim();
 16618        var xml = await Task.Run(() => XmlHelper.ToXml(root, inputObject));
 8619        var saveOptions = compress ? SaveOptions.DisableFormatting : SaveOptions.None;
 16620        Body = await Task.Run(() => xml.ToString(saveOptions));
 8621        ContentType = string.IsNullOrEmpty(contentType) ? $"application/xml; charset={Encoding.WebName}" : contentType;
 8622        StatusCode = statusCode;
 8623    }
 624    /// <summary>
 625    /// Writes a text response with the specified input object, status code, and content type.
 626    /// </summary>
 627    /// <param name="inputObject">The object to be converted to a text response.</param>
 628    /// <param name="statusCode">The HTTP status code for the response.</param>
 629    /// <param name="contentType">The MIME type of the response content.</param>
 630    public void WriteTextResponse(object? inputObject, int statusCode = StatusCodes.Status200OK, string? contentType = n
 8631        WriteTextResponseAsync(inputObject, statusCode, contentType).GetAwaiter().GetResult();
 632
 633    /// <summary>
 634    /// Asynchronously writes a text response with the specified input object, status code, and content type.
 635    /// </summary>
 636    /// <param name="inputObject">The object to be converted to a text response.</param>
 637    /// <param name="statusCode">The HTTP status code for the response.</param>
 638    /// <param name="contentType">The MIME type of the response content.</param>
 639    public async Task WriteTextResponseAsync(object? inputObject, int statusCode = StatusCodes.Status200OK, string? cont
 640    {
 31641        if (Log.IsEnabled(LogEventLevel.Debug))
 642        {
 30643            Log.Debug("Writing text response (async), StatusCode={StatusCode}, ContentType={ContentType}", statusCode, c
 644        }
 645
 31646        if (inputObject is null)
 647        {
 0648            throw new ArgumentNullException(nameof(inputObject), "Input object cannot be null for text response.");
 649        }
 650
 62651        Body = await Task.Run(() => inputObject?.ToString() ?? string.Empty);
 31652        ContentType = string.IsNullOrEmpty(contentType) ? $"text/plain; charset={Encoding.WebName}" : contentType;
 31653        StatusCode = statusCode;
 31654    }
 655
 656    /// <summary>
 657    /// Writes a form-urlencoded response with the specified input object, status code, and optional content type.
 658    /// Automatically converts the input object to a Dictionary{string, string} using <see cref="ObjectToDictionaryConve
 659    /// </summary>
 660    /// <param name="inputObject">The object to be converted to form-urlencoded data. Can be a dictionary, enumerable, o
 661    /// <param name="statusCode">The HTTP status code for the response. Defaults to 200 OK.</param>
 662    public void WriteFormUrlEncodedResponse(object? inputObject, int statusCode = StatusCodes.Status200OK) =>
 8663        WriteFormUrlEncodedResponseAsync(inputObject, statusCode).GetAwaiter().GetResult();
 664
 665    /// <summary>
 666    /// Asynchronously writes a form-urlencoded response with the specified input object, status code, and optional cont
 667    /// Automatically converts the input object to a Dictionary{string, string} using <see cref="ObjectToDictionaryConve
 668    /// </summary>
 669    /// <param name="inputObject">The object to be converted to form-urlencoded data. Can be a dictionary, enumerable, o
 670    /// <param name="statusCode">The HTTP status code for the response. Defaults to 200 OK.</param>
 671    public async Task WriteFormUrlEncodedResponseAsync(object? inputObject, int statusCode = StatusCodes.Status200OK)
 672    {
 11673        if (inputObject is null)
 674        {
 2675            throw new ArgumentNullException(nameof(inputObject), "Input object cannot be null for form-urlencoded respon
 676        }
 677
 9678        var dictionary = ObjectToDictionaryConverter.ToDictionary(inputObject);
 9679        var formContent = new FormUrlEncodedContent(dictionary);
 9680        var encodedString = await formContent.ReadAsStringAsync();
 681
 9682        await WriteTextResponseAsync(encodedString, statusCode, "application/x-www-form-urlencoded");
 9683    }
 684
 685    /// <summary>
 686    /// Writes an HTTP redirect response with the specified URL and optional message.
 687    /// </summary>
 688    /// <param name="url">The URL to redirect to.</param>
 689    /// <param name="message">An optional message to include in the response body.</param>
 690    public void WriteRedirectResponse(string url, string? message = null)
 691    {
 5692        if (Log.IsEnabled(LogEventLevel.Debug))
 693        {
 4694            Log.Debug("Writing redirect response, StatusCode={StatusCode}, Location={Location}", StatusCode, url);
 695        }
 696
 5697        if (string.IsNullOrEmpty(url))
 698        {
 0699            throw new ArgumentNullException(nameof(url), "URL cannot be null for redirect response.");
 700        }
 701        // framework hook
 5702        RedirectUrl = url;
 703
 704        // HTTP status + Location header
 5705        StatusCode = StatusCodes.Status302Found;
 5706        Headers["Location"] = url;
 707
 5708        if (message is not null)
 709        {
 710            // include a body
 1711            Body = message;
 1712            ContentType = $"text/plain; charset={Encoding.WebName}";
 713        }
 714        else
 715        {
 716            // no body: clear any existing body/headers
 4717            Body = null;
 718            //ContentType = null;
 4719            _ = Headers.Remove("Content-Length");
 720        }
 4721    }
 722
 723    /// <summary>
 724    /// Writes a binary response with the specified data, status code, and content type.
 725    /// </summary>
 726    /// <param name="data">The binary data to send in the response.</param>
 727    /// <param name="statusCode">The HTTP status code for the response.</param>
 728    /// <param name="contentType">The MIME type of the response content.</param>
 729    public void WriteBinaryResponse(byte[] data, int statusCode = StatusCodes.Status200OK, string contentType = "applica
 730    {
 1731        if (Log.IsEnabled(LogEventLevel.Debug))
 732        {
 1733            Log.Debug("Writing binary response, StatusCode={StatusCode}, ContentType={ContentType}", statusCode, content
 734        }
 735
 1736        Body = data ?? throw new ArgumentNullException(nameof(data), "Data cannot be null for binary response.");
 1737        ContentType = contentType;
 1738        StatusCode = statusCode;
 1739    }
 740    /// <summary>
 741    /// Writes a stream response with the specified stream, status code, and content type.
 742    /// </summary>
 743    /// <param name="stream">The stream to send in the response.</param>
 744    /// <param name="statusCode">The HTTP status code for the response.</param>
 745    /// <param name="contentType">The MIME type of the response content.</param>
 746    public void WriteStreamResponse(Stream stream, int statusCode = StatusCodes.Status200OK, string contentType = "appli
 747    {
 3748        if (Log.IsEnabled(LogEventLevel.Debug))
 749        {
 3750            Log.Debug("Writing stream response, StatusCode={StatusCode}, ContentType={ContentType}", statusCode, content
 751        }
 752
 3753        Body = stream;
 3754        ContentType = contentType;
 3755        StatusCode = statusCode;
 3756    }
 757    #endregion
 758
 759    #region Error Responses
 760    /// <summary>
 761    /// Structured payload for error responses.
 762    /// </summary>
 763    internal record ErrorPayload
 764    {
 26765        public string Error { get; init; } = default!;
 27766        public string? Details { get; init; }
 29767        public string? Exception { get; init; }
 28768        public string? StackTrace { get; init; }
 52769        public int Status { get; init; }
 26770        public string Reason { get; init; } = default!;
 26771        public string Timestamp { get; init; } = default!;
 20772        public string? Path { get; init; }
 20773        public string? Method { get; init; }
 774    }
 775
 776    /// <summary>
 777    /// Write an error response with a custom message.
 778    /// Chooses JSON/YAML/XML/plain-text based on override → Accept → default JSON.
 779    /// </summary>
 780    public async Task WriteErrorResponseAsync(
 781        string message,
 782        int statusCode = StatusCodes.Status500InternalServerError,
 783        string? contentType = null,
 784        string? details = null)
 785    {
 10786        if (Log.IsEnabled(LogEventLevel.Debug))
 787        {
 10788            Log.Debug("Writing error response, StatusCode={StatusCode}, ContentType={ContentType}, Message={Message}",
 10789                statusCode, contentType, message);
 790        }
 791
 10792        if (string.IsNullOrWhiteSpace(message))
 793        {
 0794            throw new ArgumentNullException(nameof(message));
 795        }
 796
 10797        Log.Warning("Writing error response with status {StatusCode}: {Message}", statusCode, message);
 798
 10799        var payload = new ErrorPayload
 10800        {
 10801            Error = message,
 10802            Details = details,
 10803            Exception = null,
 10804            StackTrace = null,
 10805            Status = statusCode,
 10806            Reason = ReasonPhrases.GetReasonPhrase(statusCode),
 10807            Timestamp = DateTime.UtcNow.ToString("o"),
 10808            Path = Request?.Path,
 10809            Method = Request?.Method
 10810        };
 811
 10812        await WriteFormattedErrorResponseAsync(payload, contentType);
 10813    }
 814
 815    /// <summary>
 816    /// Writes an error response with a custom message.
 817    /// Chooses JSON/YAML/XML/plain-text based on override → Accept → default JSON.
 818    /// </summary>
 819    /// <param name="message">The error message to include in the response.</param>
 820    /// <param name="statusCode">The HTTP status code for the response.</param>
 821    /// <param name="contentType">The MIME type of the response content.</param>
 822    /// <param name="details">Optional details to include in the response.</param>
 823    public void WriteErrorResponse(
 824      string message,
 825      int statusCode = StatusCodes.Status500InternalServerError,
 826      string? contentType = null,
 1827      string? details = null) => WriteErrorResponseAsync(message, statusCode, contentType, details).GetAwaiter().GetResu
 828
 829    /// <summary>
 830    /// Asynchronously writes an error response based on an exception.
 831    /// Chooses JSON/YAML/XML/plain-text based on override → Accept → default JSON.
 832    /// </summary>
 833    /// <param name="ex">The exception to report.</param>
 834    /// <param name="statusCode">The HTTP status code for the response.</param>
 835    /// <param name="contentType">The MIME type of the response content.</param>
 836    /// <param name="includeStack">Whether to include the stack trace in the response.</param>
 837    public async Task WriteErrorResponseAsync(
 838        Exception ex,
 839        int statusCode = StatusCodes.Status500InternalServerError,
 840        string? contentType = null,
 841        bool includeStack = true)
 842    {
 3843        if (Log.IsEnabled(LogEventLevel.Debug))
 844        {
 3845            Log.Debug("Writing error response from exception, StatusCode={StatusCode}, ContentType={ContentType}, Includ
 3846                statusCode, contentType, includeStack);
 847        }
 848
 3849        ArgumentNullException.ThrowIfNull(ex);
 850
 3851        Log.Warning(ex, "Writing error response with status {StatusCode}", statusCode);
 852
 3853        var payload = new ErrorPayload
 3854        {
 3855            Error = ex.Message,
 3856            Details = null,
 3857            Exception = ex.GetType().Name,
 3858            StackTrace = includeStack ? ex.ToString() : null,
 3859            Status = statusCode,
 3860            Reason = ReasonPhrases.GetReasonPhrase(statusCode),
 3861            Timestamp = DateTime.UtcNow.ToString("o"),
 3862            Path = Request?.Path,
 3863            Method = Request?.Method
 3864        };
 865
 3866        await WriteFormattedErrorResponseAsync(payload, contentType);
 3867    }
 868    /// <summary>
 869    /// Writes an error response based on an exception.
 870    /// Chooses JSON/YAML/XML/plain-text based on override → Accept → default JSON.
 871    /// </summary>
 872    /// <param name="ex">The exception to report.</param>
 873    /// <param name="statusCode">The HTTP status code for the response.</param>
 874    /// <param name="contentType">The MIME type of the response content.</param>
 875    /// <param name="includeStack">Whether to include the stack trace in the response.</param>
 876    public void WriteErrorResponse(
 877            Exception ex,
 878            int statusCode = StatusCodes.Status500InternalServerError,
 879            string? contentType = null,
 1880            bool includeStack = true) => WriteErrorResponseAsync(ex, statusCode, contentType, includeStack).GetAwaiter()
 881
 882    /// <summary>
 883    /// Internal dispatcher: serializes the payload according to the chosen content-type.
 884    /// </summary>
 885    private async Task WriteFormattedErrorResponseAsync(ErrorPayload payload, string? contentType = null)
 886    {
 13887        if (Log.IsEnabled(LogEventLevel.Debug))
 888        {
 13889            Log.Debug("Writing formatted error response, ContentType={ContentType}, Status={Status}", contentType, paylo
 890        }
 891
 13892        if (string.IsNullOrWhiteSpace(contentType))
 893        {
 11894            _ = Request.Headers.TryGetValue("Accept", out var acceptHeader);
 11895            contentType = (acceptHeader ?? "text/plain")
 11896                                 .ToLowerInvariant();
 897        }
 13898        if (contentType.Contains("json"))
 899        {
 3900            await WriteJsonResponseAsync(payload, payload.Status);
 901        }
 10902        else if (contentType.Contains("yaml") || contentType.Contains("yml"))
 903        {
 2904            await WriteYamlResponseAsync(payload, payload.Status);
 905        }
 8906        else if (contentType.Contains("xml"))
 907        {
 2908            await WriteXmlResponseAsync(payload, payload.Status);
 909        }
 910        else
 911        {
 912            // Plain-text fallback
 6913            var lines = new List<string>
 6914                {
 6915                    $"Status: {payload.Status} ({payload.Reason})",
 6916                    $"Error: {payload.Error}",
 6917                    $"Time: {payload.Timestamp}"
 6918                };
 919
 6920            if (!string.IsNullOrWhiteSpace(payload.Details))
 921            {
 1922                lines.Add("Details:\n" + payload.Details);
 923            }
 924
 6925            if (!string.IsNullOrWhiteSpace(payload.Exception))
 926            {
 3927                lines.Add($"Exception: {payload.Exception}");
 928            }
 929
 6930            if (!string.IsNullOrWhiteSpace(payload.StackTrace))
 931            {
 2932                lines.Add("StackTrace:\n" + payload.StackTrace);
 933            }
 934
 6935            var text = string.Join("\n", lines);
 6936            await WriteTextResponseAsync(text, payload.Status, "text/plain");
 937        }
 13938    }
 939
 940    #endregion
 941    #region HTML Response Helpers
 942
 943    /// <summary>
 944    /// Renders a template string by replacing placeholders in the format {{key}} with corresponding values from the pro
 945    /// </summary>
 946    /// <param name="template">The template string containing placeholders.</param>
 947    /// <param name="vars">A dictionary of variables to replace in the template.</param>
 948    /// <returns>The rendered string with placeholders replaced by variable values.</returns>
 949    private static string RenderInlineTemplate(
 950     string template,
 951     IReadOnlyDictionary<string, object?> vars)
 952    {
 2953        if (Log.IsEnabled(LogEventLevel.Debug))
 954        {
 2955            Log.Debug("Rendering inline template, TemplateLength={TemplateLength}, VarsCount={VarsCount}",
 2956                      template?.Length ?? 0, vars?.Count ?? 0);
 957        }
 958
 2959        if (string.IsNullOrEmpty(template))
 960        {
 0961            return string.Empty;
 962        }
 963
 2964        if (vars is null || vars.Count == 0)
 965        {
 0966            return template;
 967        }
 968
 2969        var render = RenderInline(template, vars);
 970
 2971        if (Log.IsEnabled(LogEventLevel.Debug))
 972        {
 2973            Log.Debug("Rendered template length: {RenderedLength}", render.Length);
 974        }
 975
 2976        return render;
 977    }
 978
 979    /// <summary>
 980    /// Renders a template string by replacing placeholders in the format {{key}} with corresponding values from the pro
 981    /// </summary>
 982    /// <param name="template">The template string containing placeholders.</param>
 983    /// <param name="vars">A dictionary of variables to replace in the template.</param>
 984    /// <returns>The rendered string with placeholders replaced by variable values.</returns>
 985    private static string RenderInline(string template, IReadOnlyDictionary<string, object?> vars)
 986    {
 2987        var sb = new StringBuilder(template.Length);
 988
 989        // Iterate through the template
 2990        var i = 0;
 39991        while (i < template.Length)
 992        {
 993            // opening “{{”
 37994            if (template[i] == '{' && i + 1 < template.Length && template[i + 1] == '{')
 995            {
 3996                var start = i + 2;                                        // after “{{”
 3997                var end = template.IndexOf("}}", start, StringComparison.Ordinal);
 998
 3999                if (end > start)                                          // found closing “}}”
 1000                {
 31001                    var rawKey = template[start..end].Trim();
 1002
 31003                    if (TryResolveValue(rawKey, vars, out var value) && value is not null)
 1004                    {
 31005                        _ = sb.Append(value); // append resolved value
 1006                    }
 1007                    else
 1008                    {
 01009                        _ = sb.Append("{{").Append(rawKey).Append("}}");      // leave it as-is if unknown
 1010                    }
 1011
 31012                    i = end + 2;    // jump past the “}}”
 31013                    continue;
 1014                }
 1015            }
 1016
 1017            // ordinary character
 341018            _ = sb.Append(template[i]);
 341019            i++; // move to the next character
 1020        }
 21021        return sb.ToString();
 1022    }
 1023
 1024    /// <summary>
 1025    /// Resolves a dotted path like “Request.Path” through nested dictionaries
 1026    /// and/or object properties (case-insensitive).
 1027    /// </summary>
 1028    private static bool TryResolveValue(
 1029        string path,
 1030        IReadOnlyDictionary<string, object?> root,
 1031        out object? value)
 1032    {
 31033        value = null;
 1034
 31035        if (string.IsNullOrWhiteSpace(path))
 1036        {
 01037            return false;
 1038        }
 1039
 31040        object? current = root;
 161041        foreach (var segment in path.Split('.'))
 1042        {
 51043            if (current is null)
 1044            {
 01045                return false;
 1046            }
 1047
 1048            // ① Handle dictionary look-ups (IReadOnlyDictionary or IDictionary)
 51049            if (current is IReadOnlyDictionary<string, object?> roDict)
 1050            {
 31051                if (!roDict.TryGetValue(segment, out current))
 1052                {
 01053                    return false;
 1054                }
 1055
 1056                continue;
 1057            }
 1058
 21059            if (current is IDictionary dict)
 1060            {
 01061                if (!dict.Contains(segment))
 1062                {
 01063                    return false;
 1064                }
 1065
 01066                current = dict[segment];
 01067                continue;
 1068            }
 1069
 1070            // ② Handle property look-ups via reflection
 21071            var prop = current.GetType().GetProperty(
 21072                segment,
 21073                BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
 1074
 21075            if (prop is null)
 1076            {
 01077                return false;
 1078            }
 1079
 21080            current = prop.GetValue(current);
 1081        }
 1082
 31083        value = current;
 31084        return true;
 1085    }
 1086
 1087    /// <summary>
 1088    /// Attempts to revalidate the cache based on ETag and Last-Modified headers.
 1089    /// If the resource is unchanged, sets the response status to 304 Not Modified.
 1090    /// Returns true if a 304 response was written, false otherwise.
 1091    /// </summary>
 1092    /// <param name="payload">The payload to validate.</param>
 1093    /// <param name="etag">The ETag header value.</param>
 1094    /// <param name="weakETag">Indicates if the ETag is a weak ETag.</param>
 1095    /// <param name="lastModified">The Last-Modified header value.</param>
 1096    /// <returns>True if a 304 response was written, false otherwise.</returns>
 1097    public bool RevalidateCache(object? payload,
 1098       string? etag = null,
 1099       bool weakETag = false,
 01100       DateTimeOffset? lastModified = null) => CacheRevalidation.TryWrite304(Context, payload, etag, weakETag, lastModif
 1101
 1102    /// <summary>
 1103    /// Asynchronously writes an HTML response, rendering the provided template string and replacing placeholders with v
 1104    /// </summary>
 1105    /// <param name="template">The HTML template string containing placeholders.</param>
 1106    /// <param name="vars">A dictionary of variables to replace in the template.</param>
 1107    /// <param name="statusCode">The HTTP status code for the response.</param>
 1108    public async Task WriteHtmlResponseAsync(
 1109        string template,
 1110        IReadOnlyDictionary<string, object?>? vars,
 1111        int statusCode = 200)
 1112    {
 21113        if (Log.IsEnabled(LogEventLevel.Debug))
 1114        {
 21115            Log.Debug("Writing HTML response (async), StatusCode={StatusCode}, TemplateLength={TemplateLength}", statusC
 1116        }
 1117
 21118        if (vars is null || vars.Count == 0)
 1119        {
 01120            await WriteTextResponseAsync(template, statusCode, "text/html");
 1121        }
 1122        else
 1123        {
 21124            await WriteTextResponseAsync(RenderInlineTemplate(template, vars), statusCode, "text/html");
 1125        }
 21126    }
 1127
 1128    /// <summary>
 1129    /// Asynchronously writes an HTML response, rendering the provided template byte array and replacing placeholders wi
 1130    /// </summary>
 1131    /// <param name="template">The HTML template byte array.</param>
 1132    /// <param name="vars">A dictionary of variables to replace in the template.</param>
 1133    /// <param name="statusCode">The HTTP status code for the response.</param>
 1134    /// <returns>A task representing the asynchronous operation.</returns>
 1135    public async Task WriteHtmlResponseAsync(
 1136    byte[] template,
 1137    IReadOnlyDictionary<string, object?>? vars,
 01138    int statusCode = 200) => await WriteHtmlResponseAsync(Encoding.GetString(template), vars, statusCode);
 1139
 1140    /// <summary>
 1141    /// Writes an HTML response, rendering the provided template byte array and replacing placeholders with values from 
 1142    /// </summary>
 1143    /// <param name="template">The HTML template byte array.</param>
 1144    /// <param name="vars">A dictionary of variables to replace in the template.</param>
 1145    /// <param name="statusCode">The HTTP status code for the response.</param>
 1146    public void WriteHtmlResponse(
 1147         byte[] template,
 1148         IReadOnlyDictionary<string, object?>? vars,
 01149         int statusCode = 200) => WriteHtmlResponseAsync(Encoding.GetString(template), vars, statusCode).GetAwaiter().Ge
 1150
 1151    /// <summary>
 1152    /// Asynchronously reads an HTML file, merges in placeholders from the provided dictionary, and writes the result as
 1153    /// </summary>
 1154    /// <param name="filePath">The path to the HTML file to read.</param>
 1155    /// <param name="vars">A dictionary of variables to replace in the template.</param>
 1156    /// <param name="statusCode">The HTTP status code for the response.</param>
 1157    public async Task WriteHtmlResponseFromFileAsync(
 1158        string filePath,
 1159        IReadOnlyDictionary<string, object?> vars,
 1160        int statusCode = 200)
 1161    {
 11162        if (Log.IsEnabled(LogEventLevel.Debug))
 1163        {
 11164            Log.Debug("Writing HTML response from file (async), FilePath={FilePath}, StatusCode={StatusCode}", filePath,
 1165        }
 1166
 11167        if (!File.Exists(filePath))
 1168        {
 01169            WriteTextResponse($"<!-- File not found: {filePath} -->", 404, "text/html");
 01170            return;
 1171        }
 1172
 11173        var template = await File.ReadAllTextAsync(filePath);
 11174        WriteHtmlResponseAsync(template, vars, statusCode).GetAwaiter().GetResult();
 11175    }
 1176
 1177    /// <summary>
 1178    /// Renders the given HTML string with placeholders and writes it as a response.
 1179    /// </summary>
 1180    /// <param name="template">The HTML template string containing placeholders.</param>
 1181    /// <param name="vars">A dictionary of variables to replace in the template.</param>
 1182    /// <param name="statusCode">The HTTP status code for the response.</param>
 1183    public void WriteHtmlResponse(
 1184        string template,
 1185        IReadOnlyDictionary<string, object?>? vars,
 01186        int statusCode = 200) => WriteHtmlResponseAsync(template, vars, statusCode).GetAwaiter().GetResult();
 1187
 1188    /// <summary>
 1189    /// Reads an .html file, merges in placeholders, and writes it.
 1190    /// </summary>
 1191    public void WriteHtmlResponseFromFile(
 1192        string filePath,
 1193        IReadOnlyDictionary<string, object?> vars,
 01194        int statusCode = 200) => WriteHtmlResponseFromFileAsync(filePath, vars, statusCode).GetAwaiter().GetResult();
 1195
 1196    /// <summary>
 1197    /// Writes only the specified HTTP status code, clearing any body or content type.
 1198    /// </summary>
 1199    /// <param name="statusCode">The HTTP status code to write.</param>
 1200    public void WriteStatusOnly(int statusCode)
 1201    {
 1202        // Clear any body indicators so StatusCodePages can run
 01203        ContentType = null;
 01204        StatusCode = statusCode;
 01205        Body = null;
 01206    }
 1207    #endregion
 1208
 1209    #region Apply to HttpResponse
 1210    /// <summary>
 1211    /// Applies the current KestrunResponse to the specified HttpResponse, setting status, headers, cookies, and writing
 1212    /// </summary>
 1213    /// <param name="response">The HttpResponse to apply the response to.</param>
 1214    /// <returns>A task representing the asynchronous operation.</returns>
 1215    public async Task ApplyTo(HttpResponse response)
 1216    {
 261217        if (Log.IsEnabled(LogEventLevel.Debug))
 1218        {
 251219            Log.Debug("Applying KestrunResponse to HttpResponse, StatusCode={StatusCode}, ContentType={ContentType}, Bod
 251220                StatusCode, ContentType, Body?.GetType().Name ?? "null");
 1221        }
 1222
 261223        if (response.StatusCode == StatusCodes.Status304NotModified)
 1224        {
 01225            if (Log.IsEnabled(LogEventLevel.Debug))
 1226            {
 01227                Log.Debug("Response already has status code 304 Not Modified, skipping ApplyTo");
 1228            }
 01229            return;
 1230        }
 261231        if (!string.IsNullOrEmpty(RedirectUrl))
 1232        {
 11233            response.Redirect(RedirectUrl);
 11234            return;
 1235        }
 1236
 1237        try
 1238        {
 251239            EnsureStatus(response);
 251240            ApplyHeadersAndCookies(response);
 251241            ApplyCachingHeaders(response);
 251242            if (Body is not null)
 1243            {
 221244                EnsureContentType(response);
 221245                ApplyContentDispositionHeader(response);
 221246                await WriteBodyAsync(response).ConfigureAwait(false);
 1247            }
 1248            else
 1249            {
 31250                response.ContentType = null;
 31251                response.ContentLength = null;
 31252                if (Log.IsEnabled(LogEventLevel.Debug))
 1253                {
 31254                    Log.Debug("Status-only: HasStarted={HasStarted} CL={CL} CT='{CT}'",
 31255                        response.HasStarted, response.ContentLength, response.ContentType);
 1256                }
 1257            }
 251258        }
 01259        catch (Exception ex)
 1260        {
 01261            Console.WriteLine($"Error applying response: {ex.Message}");
 1262            // Optionally, you can log the exception or handle it as needed
 01263            throw;
 1264        }
 261265    }
 1266
 1267    /// <summary>
 1268    /// Ensures the HTTP response has the correct status code and content type.
 1269    /// </summary>
 1270    /// <param name="response">The HTTP response to apply the status and content type to.</param>
 1271    private void EnsureContentType(HttpResponse response)
 1272    {
 221273        if (ContentType != response.ContentType)
 1274        {
 221275            if (!string.IsNullOrEmpty(ContentType) &&
 221276                IsTextBasedContentType(ContentType) &&
 221277                !ContentType.Contains("charset=", StringComparison.OrdinalIgnoreCase))
 1278            {
 21279                ContentType = ContentType.TrimEnd(';') + $"; charset={AcceptCharset.WebName}";
 1280            }
 221281            response.ContentType = ContentType;
 1282        }
 221283    }
 1284
 1285    /// <summary>
 1286    /// Ensures the HTTP response has the correct status code.
 1287    /// </summary>
 1288    /// <param name="response">The HTTP response to apply the status code to.</param>
 1289    private void EnsureStatus(HttpResponse response)
 1290    {
 251291        if (StatusCode != response.StatusCode)
 1292        {
 11293            response.StatusCode = StatusCode;
 1294        }
 251295    }
 1296
 1297    /// <summary>
 1298    /// Adds caching headers to the response based on the provided CacheControlHeaderValue options.
 1299    /// </summary>
 1300    /// <param name="response">The HTTP response to apply caching headers to.</param>
 1301    /// <exception cref="ArgumentNullException">Thrown when options is null.</exception>
 1302    public void ApplyCachingHeaders(HttpResponse response)
 1303    {
 251304        if (CacheControl is not null)
 1305        {
 01306            response.Headers.CacheControl = CacheControl.ToString();
 1307        }
 251308    }
 1309
 1310    /// <summary>
 1311    /// Applies the Content-Disposition header to the HTTP response.
 1312    /// </summary>
 1313    /// <param name="response">The HTTP response to apply the header to.</param>
 1314    private void ApplyContentDispositionHeader(HttpResponse response)
 1315    {
 221316        if (ContentDisposition.Type == ContentDispositionType.NoContentDisposition)
 1317        {
 201318            return;
 1319        }
 1320
 21321        if (Log.IsEnabled(LogEventLevel.Debug))
 1322        {
 21323            Log.Debug("Setting Content-Disposition header, Type={Type}, FileName={FileName}",
 21324                      ContentDisposition.Type, ContentDisposition.FileName);
 1325        }
 1326
 21327        var dispositionValue = ContentDisposition.Type switch
 21328        {
 21329            ContentDispositionType.Attachment => "attachment",
 01330            ContentDispositionType.Inline => "inline",
 01331            _ => throw new InvalidOperationException("Invalid Content-Disposition type")
 21332        };
 1333
 21334        if (string.IsNullOrEmpty(ContentDisposition.FileName) && Body is IFileInfo fi)
 1335        {
 1336            // default filename: use the file's name
 11337            ContentDisposition.FileName = fi.Name;
 1338        }
 1339
 21340        if (!string.IsNullOrEmpty(ContentDisposition.FileName))
 1341        {
 21342            var escapedFileName = WebUtility.UrlEncode(ContentDisposition.FileName);
 21343            dispositionValue += $"; filename=\"{escapedFileName}\"";
 1344        }
 1345
 21346        response.Headers.Append("Content-Disposition", dispositionValue);
 21347    }
 1348
 1349    /// <summary>
 1350    /// Applies headers and cookies to the HTTP response.
 1351    /// </summary>
 1352    /// <param name="response">The HTTP response to apply the headers and cookies to.</param>
 1353    private void ApplyHeadersAndCookies(HttpResponse response)
 1354    {
 251355        if (Headers is not null)
 1356        {
 501357            foreach (var kv in Headers)
 1358            {
 01359                response.Headers[kv.Key] = kv.Value;
 1360            }
 1361        }
 251362        if (Cookies is not null)
 1363        {
 01364            foreach (var cookie in Cookies)
 1365            {
 01366                response.Headers.Append("Set-Cookie", cookie);
 1367            }
 1368        }
 251369    }
 1370
 1371    /// <summary>
 1372    /// Writes the response body to the HTTP response.
 1373    /// </summary>
 1374    /// <param name="response">The HTTP response to write to.</param>
 1375    /// <returns>A task representing the asynchronous operation.</returns>
 1376    private async Task WriteBodyAsync(HttpResponse response)
 1377    {
 221378        var bodyValue = Body; // capture to avoid nullability warnings when mutated in default
 1379        switch (bodyValue)
 1380        {
 1381            case IFileInfo fileInfo:
 11382                Log.Debug("Sending file {FileName} (Length={Length})", fileInfo.Name, fileInfo.Length);
 11383                response.ContentLength = fileInfo.Length;
 11384                response.Headers.LastModified = fileInfo.LastModified.ToString("R");
 11385                await response.SendFileAsync(
 11386                    file: fileInfo,
 11387                    offset: 0,
 11388                    count: fileInfo.Length,
 11389                    cancellationToken: response.HttpContext.RequestAborted
 11390                );
 11391                break;
 1392
 1393            case byte[] bytes:
 11394                response.ContentLength = bytes.LongLength;
 11395                await response.Body.WriteAsync(bytes, response.HttpContext.RequestAborted);
 11396                await response.Body.FlushAsync(response.HttpContext.RequestAborted);
 11397                break;
 1398
 1399            case Stream stream:
 21400                var seekable = stream.CanSeek;
 21401                Log.Debug("Sending stream (seekable={Seekable}, len={Len})",
 21402                          seekable, seekable ? stream.Length : -1);
 1403
 21404                if (seekable)
 1405                {
 11406                    response.ContentLength = stream.Length;
 11407                    stream.Position = 0;
 1408                }
 1409                else
 1410                {
 11411                    response.ContentLength = null;
 1412                }
 1413
 1414                const int BufferSize = 64 * 1024; // 64 KB
 21415                var buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
 1416                try
 1417                {
 1418                    int bytesRead;
 41419                    while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, BufferSize), response.HttpContext.Requ
 1420                    {
 21421                        await response.Body.WriteAsync(buffer.AsMemory(0, bytesRead), response.HttpContext.RequestAborte
 1422                    }
 21423                }
 1424                finally
 1425                {
 21426                    ArrayPool<byte>.Shared.Return(buffer);
 1427                }
 21428                await response.Body.FlushAsync(response.HttpContext.RequestAborted);
 21429                break;
 1430
 1431            case string str:
 181432                var data = AcceptCharset.GetBytes(str);
 181433                response.ContentLength = data.Length;
 181434                await response.Body.WriteAsync(data, response.HttpContext.RequestAborted);
 181435                await response.Body.FlushAsync(response.HttpContext.RequestAborted);
 181436                break;
 1437
 1438            default:
 01439                var bodyType = bodyValue?.GetType().Name ?? "null";
 01440                Body = "Unsupported body type: " + bodyType;
 01441                Log.Warning("Unsupported body type: {BodyType}", bodyType);
 01442                response.StatusCode = StatusCodes.Status500InternalServerError;
 01443                response.ContentType = "text/plain; charset=utf-8";
 01444                response.ContentLength = Body.ToString()?.Length ?? null;
 1445                break;
 1446        }
 221447    }
 1448    #endregion
 1449}

Methods/Properties

.ctor(Kestrun.Models.KestrunRequest,System.Int32)
.cctor()
get_Context()
get_StatusCode()
get_Headers()
get_ContentType()
get_Body()
get_RedirectUrl()
get_Cookies()
get_Encoding()
get_ContentDisposition()
get_Request()
get_AcceptCharset()
get_BodyAsyncThreshold()
get_CacheControl()
GetSafeCurrentDirectoryOrBaseDirectory()
GetSafeCurrentDirectoryForLogging()
GetHeader(System.String)
DetermineContentType(System.String,System.String)
IsTextBasedContentType(System.String)
WriteFileResponse(System.String,System.String,System.Int32)
WriteJsonResponse(System.Object,System.Int32)
WriteJsonResponseAsync()
WriteJsonResponse(System.Object,Newtonsoft.Json.JsonSerializerSettings,System.Int32,System.String)
WriteJsonResponseAsync()
WriteJsonResponse(System.Object,System.Int32,System.Boolean,System.Int32,System.String)
WriteJsonResponseAsync()
WriteCborResponseAsync()
WriteCborResponse(System.Object,System.Int32,System.String)
WriteBsonResponseAsync()
WriteBsonResponse(System.Object,System.Int32,System.String)
WriteResponseAsync()
WriteResponse(System.Object,System.Int32)
WriteCsvResponse(System.Object,System.Int32,System.String,CsvHelper.Configuration.CsvConfiguration)
WriteCsvResponseAsync()
WriteYamlResponse(System.Object,System.Int32,System.String)
WriteYamlResponseAsync()
WriteXmlResponse(System.Object,System.Int32,System.String,System.String,System.Boolean)
WriteXmlResponseAsync()
WriteTextResponse(System.Object,System.Int32,System.String)
WriteTextResponseAsync()
WriteFormUrlEncodedResponse(System.Object,System.Int32)
WriteFormUrlEncodedResponseAsync()
WriteRedirectResponse(System.String,System.String)
WriteBinaryResponse(System.Byte[],System.Int32,System.String)
WriteStreamResponse(System.IO.Stream,System.Int32,System.String)
get_Error()
get_Details()
get_Exception()
get_StackTrace()
get_Status()
get_Reason()
get_Timestamp()
get_Path()
get_Method()
WriteErrorResponseAsync()
WriteErrorResponse(System.String,System.Int32,System.String,System.String)
WriteErrorResponseAsync()
WriteErrorResponse(System.Exception,System.Int32,System.String,System.Boolean)
WriteFormattedErrorResponseAsync()
RenderInlineTemplate(System.String,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Object>)
RenderInline(System.String,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Object>)
TryResolveValue(System.String,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Object>,System.Object&)
RevalidateCache(System.Object,System.String,System.Boolean,System.Nullable`1<System.DateTimeOffset>)
WriteHtmlResponseAsync()
WriteHtmlResponseAsync()
WriteHtmlResponse(System.Byte[],System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Object>,System.Int32)
WriteHtmlResponseFromFileAsync()
WriteHtmlResponse(System.String,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Object>,System.Int32)
WriteHtmlResponseFromFile(System.String,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Object>,System.Int32)
WriteStatusOnly(System.Int32)
ApplyTo()
EnsureContentType(Microsoft.AspNetCore.Http.HttpResponse)
EnsureStatus(Microsoft.AspNetCore.Http.HttpResponse)
ApplyCachingHeaders(Microsoft.AspNetCore.Http.HttpResponse)
ApplyContentDispositionHeader(Microsoft.AspNetCore.Http.HttpResponse)
ApplyHeadersAndCookies(Microsoft.AspNetCore.Http.HttpResponse)
WriteBodyAsync()