-
-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Adds response header handling and unit tests (#12)
Co-authored-by: Egil Hansen <egil@assimilated.dk>
- Loading branch information
Showing
9 changed files
with
1,012 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Linq; | ||
using System.Text; | ||
using System.Threading.Tasks; | ||
|
||
namespace Htmxor.Configuration; | ||
|
||
public enum TriggerTiming | ||
{ | ||
/// <summary> | ||
/// Trigger events as soon as the response is received | ||
/// </summary> | ||
Default, | ||
|
||
/// <summary> | ||
/// Trigger events after the settling step | ||
/// </summary> | ||
AfterSettle, | ||
|
||
/// <summary> | ||
/// Trigger events after the swap step | ||
/// </summary> | ||
AfterSwap | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,267 @@ | ||
using Microsoft.AspNetCore.Http; | ||
using System.Text; | ||
using System.Text.Json; | ||
using System.Text.Json.Nodes; | ||
using System.Text.Json.Serialization; | ||
using Htmxor.Configuration; | ||
using Htmxor.Http.Models; | ||
using Microsoft.AspNetCore.Http; | ||
using Microsoft.Extensions.Primitives; | ||
|
||
namespace Htmxor.Http; | ||
|
||
public class HtmxResponse(HttpContext context) | ||
{ | ||
private readonly IHeaderDictionary _headers = context.Response.Headers; | ||
|
||
private static readonly JsonSerializerOptions _serializerOptions = new() | ||
{ | ||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, | ||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, | ||
Converters = | ||
{ | ||
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower, false) | ||
} | ||
}; | ||
|
||
/// <summary> | ||
/// Allows you to do a client-side redirect that does not do a full page reload. | ||
/// </summary> | ||
/// <param name="path"></param> | ||
/// <param name="context"></param> | ||
/// <returns></returns> | ||
public HtmxResponse Location(string path, AjaxContext? context = null) | ||
{ | ||
if (context == null) | ||
_headers[HtmxResponseHeaderNames.Location] = path; | ||
else | ||
{ | ||
JsonObject json = new(); | ||
json.Add("path", JsonValue.Create(path)); | ||
|
||
var ctxNode = JsonSerializer.SerializeToNode(context)!.AsObject(); | ||
|
||
foreach (var prop in ctxNode.AsEnumerable()) | ||
{ | ||
if (prop.Value != null) | ||
json.Add(prop.Key, prop.Value.DeepClone()); | ||
} | ||
|
||
_headers[HtmxResponseHeaderNames.Location] = JsonSerializer.Serialize(json); | ||
} | ||
|
||
return this; | ||
} | ||
|
||
/// <summary> | ||
/// Pushes a new url onto the history stack. | ||
/// </summary> | ||
/// <param name="url"></param> | ||
/// <returns></returns> | ||
public HtmxResponse PushUrl(string url) | ||
{ | ||
_headers[HtmxResponseHeaderNames.PushUrl] = url; | ||
|
||
return this; | ||
} | ||
|
||
/// <summary> | ||
/// Prevents the browser’s history from being updated. | ||
/// Overwrites PushUrl response if already present. | ||
/// </summary> | ||
/// <returns></returns> | ||
public HtmxResponse PreventBrowserHistoryUpdate() | ||
{ | ||
_headers[HtmxResponseHeaderNames.PushUrl] = "false"; | ||
|
||
return this; | ||
} | ||
|
||
/// <summary> | ||
/// Prevents the browser’s current url from being updated | ||
/// Overwrites ReplaceUrl response if already present. | ||
/// </summary> | ||
/// <returns></returns> | ||
public HtmxResponse PreventBrowserCurrentUrlUpdate() | ||
{ | ||
_headers[HtmxResponseHeaderNames.ReplaceUrl] = "false"; | ||
|
||
return this; | ||
} | ||
|
||
/// <summary> | ||
/// Can be used to do a client-side redirect to a new location. | ||
/// </summary> | ||
/// <param name="url"></param> | ||
/// <returns></returns> | ||
public HtmxResponse Redirect(string url) | ||
{ | ||
_headers[HtmxResponseHeaderNames.Redirect] = url; | ||
|
||
return this; | ||
} | ||
|
||
/// <summary> | ||
/// Enables a client-side full refresh of the page | ||
/// </summary> | ||
/// <returns></returns> | ||
public HtmxResponse Refresh() | ||
{ | ||
_headers[HtmxResponseHeaderNames.Refresh] = "true"; | ||
|
||
return this; | ||
} | ||
|
||
/// <summary> | ||
/// Replaces the current URL in the location bar | ||
/// </summary> | ||
/// <param name="url"></param> | ||
/// <returns></returns> | ||
public HtmxResponse ReplaceUrl(string url) | ||
{ | ||
_headers[HtmxResponseHeaderNames.ReplaceUrl] = url; | ||
|
||
return this; | ||
} | ||
|
||
/// <summary> | ||
/// Allows you to specify how the response will be swapped. | ||
/// </summary> | ||
/// <param name="swapStyle"></param> | ||
/// <returns></returns> | ||
public HtmxResponse Reswap(SwapStyle swapStyle) | ||
{ | ||
var style = swapStyle switch | ||
{ | ||
SwapStyle.InnerHTML => "innerHTML", | ||
SwapStyle.OuterHTML => "outerHTML", | ||
_ => swapStyle.ToString().ToLowerInvariant() | ||
}; | ||
|
||
_headers[HtmxResponseHeaderNames.Reswap] = style; | ||
|
||
return this; | ||
} | ||
|
||
/// <summary> | ||
/// A CSS selector that updates the target of the content update to a different element on the page. | ||
/// </summary> | ||
/// <param name="selector"></param> | ||
/// <returns></returns> | ||
public HtmxResponse Retarget(string selector) | ||
{ | ||
_headers[HtmxResponseHeaderNames.Retarget] = selector; | ||
|
||
return this; | ||
} | ||
|
||
/// <summary> | ||
/// A CSS selector that allows you to choose which part of the response is used to be swapped in. | ||
/// </summary> | ||
/// <param name="selector"></param> | ||
/// <returns></returns> | ||
public HtmxResponse Reselect(string selector) | ||
{ | ||
_headers[HtmxResponseHeaderNames.Reselect] = selector; | ||
|
||
return this; | ||
} | ||
|
||
/// <summary> | ||
/// Allows you to trigger client-side events. | ||
/// </summary> | ||
/// <param name="eventName"></param> | ||
/// <param name="detail"></param> | ||
/// <param name="timing"></param> | ||
/// <returns></returns> | ||
public HtmxResponse Trigger(string eventName, object? detail = null, TriggerTiming timing = TriggerTiming.Default) | ||
{ | ||
var headerKey = timing switch | ||
{ | ||
TriggerTiming.AfterSwap => HtmxResponseHeaderNames.TriggerAfterSwap, | ||
TriggerTiming.AfterSettle => HtmxResponseHeaderNames.TriggerAfterSettle, | ||
_ => HtmxResponseHeaderNames.Trigger | ||
}; | ||
|
||
MergeTrigger(headerKey, eventName, detail); | ||
|
||
return this; | ||
} | ||
|
||
/// <summary> | ||
/// Aggregate existing headers and merge event with detail into the result | ||
/// </summary> | ||
/// <param name="headerKey"></param> | ||
/// <param name="eventName"></param> | ||
/// <param name="detail"></param> | ||
private void MergeTrigger(string headerKey, string eventName, object? detail = null) | ||
{ | ||
var (sb, isComplex) = BuildExistingTriggerJson(headerKey); | ||
|
||
// If this event doesn't have a detail and any existing events also | ||
// don't have details we can simplify the output to comma-delimited event names | ||
if (detail == null && !isComplex) | ||
{ | ||
var header = _headers[headerKey]; | ||
_headers[headerKey] = StringValues.Concat(header, eventName).ToString(); | ||
} | ||
else | ||
{ | ||
var detailJson = JsonSerializer.Serialize(detail, _serializerOptions); | ||
|
||
if (sb.Length > 0) sb.Append(','); | ||
|
||
// Append the key/value to the output json | ||
sb.Append($"\"{eventName}\":{detailJson}"); | ||
|
||
// Wrap the entire sb contents to turn it into valid json | ||
sb.Insert(0, '{'); | ||
sb.Append('}'); | ||
|
||
_headers[headerKey] = sb.ToString(); | ||
} | ||
} | ||
|
||
/// <summary> | ||
/// Create a stringBuilder containing the serialized json for the aggregated properties across | ||
/// all header values that exist for this header key - duplicate keys are not removed for performance | ||
/// reasons because the json produced is still valid | ||
/// This approach does not validate any syntax of existing headers as a performance consideration. | ||
/// </summary> | ||
/// <param name="headerKey"></param> | ||
/// <returns></returns> | ||
private (StringBuilder, bool) BuildExistingTriggerJson(string headerKey) | ||
{ | ||
var isComplex = false; | ||
StringBuilder sb = new(); | ||
|
||
var header = _headers[headerKey]; | ||
|
||
// header as StringValues can have no values, one value, or many values | ||
// so foreach is safest way to iterate through multiple possible headers | ||
foreach (var headerValue in header) | ||
{ | ||
if (headerValue is null) continue; | ||
|
||
// Is this headerValue possibly a Json object? | ||
if (headerValue.StartsWith("{")) | ||
{ | ||
isComplex = true; | ||
|
||
if (sb.Length > 0) sb.Append(','); | ||
sb.Append(headerValue.Substring(1, headerValue.Length - 2)); | ||
} | ||
else | ||
{ | ||
var eventNames = headerValue.Split(','); | ||
|
||
foreach (var name in eventNames) | ||
{ | ||
if (sb.Length > 0) sb.Append(','); | ||
sb.Append("\"" + name + "\":\"\""); | ||
} | ||
} | ||
} | ||
|
||
return (sb, isComplex); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Linq; | ||
using System.Text; | ||
using System.Text.Json.Serialization; | ||
using System.Threading.Tasks; | ||
|
||
namespace Htmxor.Http.Models; | ||
|
||
/// <summary> | ||
/// Represents a location event with various properties. | ||
/// </summary> | ||
public class AjaxContext | ||
{ | ||
/// <summary> | ||
/// Gets or sets the source element of the request. | ||
/// </summary> | ||
[JsonPropertyName("source")] | ||
public string? Source { get; set; } | ||
|
||
/// <summary> | ||
/// Gets or sets an event that "triggered" the request. | ||
/// </summary> | ||
[JsonPropertyName("event")] | ||
public string? Event { get; set; } | ||
|
||
/// <summary> | ||
/// Gets or sets a callback that will handle the response HTML. | ||
/// </summary> | ||
[JsonPropertyName("handler")] | ||
public string? Handler { get; set; } | ||
|
||
/// <summary> | ||
/// Gets or sets the target to swap the response into. | ||
/// </summary> | ||
[JsonPropertyName("target")] | ||
public string? Target { get; set; } | ||
|
||
/// <summary> | ||
/// Gets or sets how the response will be swapped in relative to the target. | ||
/// </summary> | ||
[JsonPropertyName("swap")] | ||
public string? Swap { get; set; } | ||
|
||
/// <summary> | ||
/// Gets or sets values to submit with the request. | ||
/// </summary> | ||
[JsonPropertyName("values")] | ||
public string? Values { get; set; } | ||
|
||
/// <summary> | ||
/// Gets or sets headers to submit with the request. | ||
/// </summary> | ||
[JsonPropertyName("headers")] | ||
public string? Headers { get; set; } | ||
|
||
/// <summary> | ||
/// Gets or sets allows you to select the content you want swapped from a response. | ||
/// </summary> | ||
[JsonPropertyName("select")] | ||
public string? Select { get; set; } | ||
} | ||
|
Oops, something went wrong.