Skip to content

Commit

Permalink
Improve Response Headers (#23)
Browse files Browse the repository at this point in the history
* Temporary disable MacOS on GitHub because .NET 9.0 Preview failure

* refactor mark static local function

* ResponseHeaders support key: value or key=value; multi handlers

* Test ResponseHeadersHelper.Parse

* Test PrepareResponse

* Update tool minver
  • Loading branch information
thohng committed Jun 4, 2024
1 parent 535e10b commit ceaa1ce
Show file tree
Hide file tree
Showing 10 changed files with 1,249 additions and 84 deletions.
2 changes: 1 addition & 1 deletion .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"minver-cli": {
"version": "4.3.0",
"version": "5.0.0",
"commands": ["minver"]
}
}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/aspnet-core.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ jobs:
strategy:
fail-fast: false
matrix:
runs-on: [macos-11, ubuntu-22.04, windows-latest]
#runs-on: [macos-11, ubuntu-22.04, windows-latest] disable
runs-on: [ubuntu-22.04, windows-latest]
name: ${{ matrix.runs-on }}
runs-on: ${{ matrix.runs-on }}
steps:
Expand Down
2 changes: 0 additions & 2 deletions src/Hosting/AppOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,4 @@ public class AppOptions
/// /debug/routes
/// </summary>
public string? DebugRoutes { get; set; }

public ResponseHeadersOptions ResponseHeaders { get; set; } = new ResponseHeadersOptions();
}
2 changes: 1 addition & 1 deletion src/Hosting/MountFileHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ public static MountFileProviderOptions ParserOptions(IConfigurationRoot configur
}
}

bool TryParseKeyValue(string keyValue, out string key, out string? value)
static bool TryParseKeyValue(string keyValue, out string key, out string? value)
{
var pos = keyValue.IndexOf('=');
if (pos > 0 && pos < keyValue.Length - 1)
Expand Down
117 changes: 56 additions & 61 deletions src/Hosting/ResponseHeadersHandler.cs
Original file line number Diff line number Diff line change
@@ -1,89 +1,84 @@
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;

namespace NetLah.Extensions.SpaServices.Hosting;

internal class ResponseHeadersHandler
internal class ResponseHeadersHandler(ILogger logger, ResponseHeadersOptions responseHeaders, Action<StaticFileResponseContext> originalResponseHandler)
{
private readonly ILogger _logger;
private readonly List<KeyValuePair<string, StringValues>> _headers;
private readonly HashSet<string?>? _contentTypeHashSet;
private readonly List<string?>? _contentTypeList;
private readonly Func<int, bool> _matchStatusCode;
private readonly Func<string, bool> _matchContentType;
private readonly HashSet<int>? _statusCodes;
private readonly ILogger _logger = logger;
private readonly WeakReference _originalResponseHandler = new(originalResponseHandler ?? (_ => { }));
private readonly ResponseHandlerEntry[] _handlers = [
.. (responseHeaders.DefaultHandler is { } handler ? [handler] : Array.Empty<ResponseHandlerEntry>()),
.. responseHeaders.Handlers,
];

public ResponseHeadersHandler(ILogger logger, ResponseHeadersOptions responseHeaders)
public void PrepareResponse(StaticFileResponseContext context)
{
_logger = logger;
_headers = responseHeaders.Headers
.Select(kv => new KeyValuePair<string, StringValues>(kv.Key, new StringValues(kv.Value)))
.ToList();
var contentType = context.Context.Response.ContentType;
var statusCode = context.Context.Response.StatusCode;

if (!responseHeaders.IsEnabled || _headers.Count == 0)
if (string.IsNullOrEmpty(contentType) || statusCode > 399)
{
_matchStatusCode = _ => false;
_matchContentType = _ => false;
logger.LogWarning("ResponseHeadersHandler is disabled");
GetOriginalResponseHandler()?.Invoke(context);
return;
}
else
{
if (responseHeaders.FilterHttpStatusCode is { } listHttpStatusCode
&& listHttpStatusCode.Count != 0)
{
_statusCodes = [.. listHttpStatusCode];
_matchStatusCode = statusCode => _statusCodes.Contains(statusCode);
logger.LogInformation("ResponseHeadersHandler StatusCode in list");
}
else
{
_matchStatusCode = _ => true;
}

if (responseHeaders.FilterContentType is not { } listContentType
|| listContentType.Count == 0
|| responseHeaders.IsAnyContentType)
{
_matchContentType = _ => true;
logger.LogInformation("ResponseHeadersHandler all content-type");
}
else
foreach (var handler in _handlers)
{
if ((handler.StatusCode.Count == 0 || handler.StatusCode.Contains(statusCode))
&& (
(handler.ContentTypeMatchEq.Count == 0 && handler.ContentTypeMatchContain.Length == 0 && handler.ContentTypeMatchStartWith.Length == 0)
|| (handler.ContentTypeMatchEq.Count > 0 && handler.ContentTypeMatchEq.Contains(contentType))
|| (handler.ContentTypeMatchContain.Length > 0 && MatchContain(handler.ContentTypeMatchContain))
|| (handler.ContentTypeMatchStartWith.Length > 0 && MatchStartWith(handler.ContentTypeMatchStartWith))
))
{
_contentTypeHashSet = listContentType.ToHashSet(StringComparer.InvariantCultureIgnoreCase);
_contentTypeList = [.. _contentTypeHashSet];

if (responseHeaders.IsContentTypeContainsMatch)
if (handler.StatusCode.Count == 0)
{
_matchContentType = contentType => _contentTypeList.Any(item => !string.IsNullOrEmpty(item) && item.Contains(contentType));
logger.LogInformation("ResponseHeadersHandler content-type -contains {contentTypes}", _contentTypeList);
_logger.LogDebug("Add headers for {contentType}", contentType);
}
else
{
_matchContentType = contentType => _contentTypeHashSet.Contains(contentType);
logger.LogInformation("ResponseHeadersHandler content-type -eq {contentTypes}", _contentTypeList);
_logger.LogDebug("Add headers for {contentType} statusCode={statusCode}", contentType, statusCode);
}

foreach (var item in handler.Headers)
{
context.Context.Response.Headers.Add(item);
}

GetOriginalResponseHandler()?.Invoke(context);
return;
}
}
}

public void PrepareResponse(StaticFileResponseContext context)
{
var contentType = context.Context.Response.ContentType;
var statusCode = context.Context.Response.StatusCode;

if (string.IsNullOrEmpty(contentType)
|| statusCode > 399
|| !_matchStatusCode(statusCode)
|| !_matchContentType(contentType))
bool MatchContain(string[] contentTypeMatchContain)
{
return;
foreach (var item in contentTypeMatchContain)
{
if (contentType.Contains(item))
{
return true;
}
}
return false;
}

_logger.LogDebug("Add headers");
foreach (var item in _headers)
bool MatchStartWith(string[] contentTypeMatchStartWith)
{
context.Context.Response.Headers.Add(item);
foreach (var item in contentTypeMatchStartWith)
{
if (contentType.StartsWith(item))
{
return true;
}
}
return false;
}

GetOriginalResponseHandler()?.Invoke(context);

Action<StaticFileResponseContext>? GetOriginalResponseHandler() => _originalResponseHandler.IsAlive ? _originalResponseHandler.Target as Action<StaticFileResponseContext> : null;
}

}
162 changes: 162 additions & 0 deletions src/Hosting/ResponseHeadersHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;

namespace NetLah.Extensions.SpaServices.Hosting;

internal static class ResponseHeadersHelper
{
private static readonly StringComparison DefaultStringComparison = StringComparison.OrdinalIgnoreCase;

private static readonly StringComparer DefaultStringComparer = StringComparer.OrdinalIgnoreCase;

private static readonly HashSet<string> PropertySet = new(DefaultStringComparer) {
nameof(BaseResponseHeadersConfigurationOptions.ContentType),
nameof(BaseResponseHeadersConfigurationOptions.ContentTypeContain),
nameof(BaseResponseHeadersConfigurationOptions.ContentTypeStartWith),
nameof(BaseResponseHeadersConfigurationOptions.Headers),
nameof(BaseResponseHeadersConfigurationOptions.StatusCode),
nameof(ResponseHeadersConfigurationOptions.IsEnabled),
"IsAnyContentType", // legacy value
"IsContentTypeContainsMatch", // legacy value
};

//private static readonly string[] PropertyNames = [.. PropertySet];

public static ResponseHeadersOptions Parse(IConfigurationRoot? configurationRoot, string sectionName)
{
ResponseHandlerEntry? defaultHandlerEntry = null;
var isEnabled = false;
var handlers = new List<ResponseHandlerEntry>();

if (configurationRoot != null)
{
var configuration = configurationRoot.GetSection(sectionName);
var configOptions = new ResponseHeadersConfigurationOptions();
configuration.Bind(configOptions);
if (configOptions.IsEnabled)
{
isEnabled = true;
defaultHandlerEntry = ParseHandler(configOptions, configuration);
}

foreach (var item in configuration.GetChildren())
{
var key = item.Key;
if (item.Value == null && int.TryParse(key, out var _))
{
var configOptions1 = new BaseResponseHeadersConfigurationOptions();
item.Bind(configOptions1);
var handlerEntry = ParseHandler(configOptions1, item);
if (handlerEntry.Headers.Length > 0)
{
handlers.Add(handlerEntry);
}
}
}
}

return new ResponseHeadersOptions
{
IsEnabled = isEnabled,
DefaultHandler = defaultHandlerEntry?.Headers.Length > 0 ? defaultHandlerEntry : default,
Handlers = [.. handlers],
};
}

private static ResponseHandlerEntry ParseHandler(BaseResponseHeadersConfigurationOptions options, IConfigurationSection configuration)
{
var headers = new Dictionary<string, string>();

foreach (var item in options.Headers ?? [])
{
if (TryParseKeyValue(item, out var key, out var value)
&& !string.IsNullOrEmpty(key)
&& !string.IsNullOrEmpty(value))
{
headers[key] = value;
}
}

foreach (var item in configuration.GetChildren())
{
var key = item.Key;
if (item.Value is { } value && !string.IsNullOrEmpty(value) && !int.TryParse(key, out var _))
{
if (nameof(BaseResponseHeadersConfigurationOptions.ContentType).Equals(key, DefaultStringComparison))
{
if (options.ContentType == null || options.ContentType.Count == 0)
{
options.ContentType = [value];
}
}
else if (nameof(BaseResponseHeadersConfigurationOptions.ContentTypeContain).Equals(key, DefaultStringComparison))
{
if (options.ContentTypeContain == null || options.ContentTypeContain.Count == 0)
{
options.ContentTypeContain = [value];
}
}
else if (nameof(BaseResponseHeadersConfigurationOptions.ContentTypeStartWith).Equals(key, DefaultStringComparison))
{
if (options.ContentTypeStartWith == null || options.ContentTypeStartWith.Count == 0)
{
options.ContentTypeStartWith = [value];
}
}
else if (nameof(BaseResponseHeadersConfigurationOptions.StatusCode).Equals(key, DefaultStringComparison) && int.TryParse(value, out var statusCode))
{
if (options.StatusCode == null || options.StatusCode.Count == 0)
{
options.StatusCode = [statusCode];
}
}
else if (!PropertySet.Contains(key))
{
headers[key] = value;
}
}
else if (nameof(BaseResponseHeadersConfigurationOptions.Headers).Equals(key, DefaultStringComparison))
{
foreach (var item1 in item.GetChildren())
{
if (item1.Value is { } value1 && item1.Key is { } key1 && !string.IsNullOrEmpty(key1) && !string.IsNullOrEmpty(value1) && !int.TryParse(key1, out var _))
{
headers[key1] = value1;
}
}
}
}

var headersStringValues = headers
.Select(kv => new KeyValuePair<string, StringValues>(kv.Key, new StringValues(kv.Value)))
.ToArray();

HashSet<string> contentTypesSet = [.. options.ContentType ?? [], .. options.ContentTypeStartWith ?? [], .. options.ContentTypeContain ?? []];

var handlerEntry = new ResponseHandlerEntry(
options.ContentType?.Where(v => !string.IsNullOrEmpty(v)).ToHashSet() ?? [],
options.ContentTypeStartWith?.Where(v => !string.IsNullOrEmpty(v)).ToArray() ?? [],
options.ContentTypeContain?.Where(v => !string.IsNullOrEmpty(v)).ToArray() ?? [],
options.StatusCode?.Where(v => v > 0).ToHashSet() ?? [],
headersStringValues,
[.. headers.Keys.OrderBy(s => s, DefaultStringComparer)],
[.. contentTypesSet.OrderBy(s => s, DefaultStringComparer)]);

return handlerEntry;

static bool TryParseKeyValue(string keyValue, out string key, out string? value)
{
var pos = keyValue.IndexOf('=');
if (pos > 0 && pos < keyValue.Length - 1)
{
key = keyValue[..pos];
value = keyValue[(pos + 1)..];
return true;
}

key = string.Empty;
value = null;
return false;
}
}
}
44 changes: 35 additions & 9 deletions src/Hosting/ResponseHeadersOptions.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,42 @@
namespace NetLah.Extensions.SpaServices.Hosting;
using Microsoft.Extensions.Primitives;

public class ResponseHeadersOptions
{
public bool IsEnabled { get; set; } = true;
namespace NetLah.Extensions.SpaServices.Hosting;

public bool IsContentTypeContainsMatch { get; set; } = true;
internal class BaseResponseHeadersConfigurationOptions
{
public HashSet<int>? StatusCode { get; set; }
public HashSet<string>? ContentType { get; set; }
public HashSet<string>? ContentTypeContain { get; set; }
public HashSet<string>? ContentTypeStartWith { get; set; }
public List<string>? Headers { get; set; }
}

public bool IsAnyContentType { get; set; } = false;
internal class ResponseHeadersConfigurationOptions : BaseResponseHeadersConfigurationOptions
{
public bool IsEnabled { get; set; } = true;

public Dictionary<string, string?> Headers { get; set; } = [];
}

public List<string?>? FilterContentType { get; set; }
internal class ResponseHeadersOptions
{
public bool IsEnabled { get; set; } = true;
public ResponseHandlerEntry[] Handlers { get; set; } = [];
public ResponseHandlerEntry? DefaultHandler { get; set; }
}

public List<int>? FilterHttpStatusCode { get; set; }
internal class ResponseHandlerEntry(HashSet<string> contentType,
string[] contentTypeMatchStartWith,
string[] contentTypeMatchContain,
HashSet<int> statusCode,
KeyValuePair<string, StringValues>[] headers,
string[] headerNames,
string[] contentTypes)
{
public HashSet<string> ContentTypeMatchEq { get; set; } = contentType;
public string[] ContentTypeMatchStartWith { get; set; } = contentTypeMatchStartWith;
public string[] ContentTypeMatchContain { get; set; } = contentTypeMatchContain;
public HashSet<int> StatusCode { get; set; } = statusCode;
public KeyValuePair<string, StringValues>[] Headers { get; set; } = headers;
public string[] HeaderNames { get; set; } = headerNames;
public string[] ContentTypes { get; set; } = contentTypes;
}
Loading

0 comments on commit ceaa1ce

Please sign in to comment.