Skip to content

Commit

Permalink
feat: Adds response header handling and unit tests (#12)
Browse files Browse the repository at this point in the history
Co-authored-by: Egil Hansen <egil@assimilated.dk>
  • Loading branch information
tanczosm and egil authored Apr 20, 2024
1 parent d2968d2 commit 5404cb3
Show file tree
Hide file tree
Showing 9 changed files with 1,012 additions and 1 deletion.
25 changes: 25 additions & 0 deletions src/Htmxor/Configuration/TriggerTiming.cs
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
}
262 changes: 261 additions & 1 deletion src/Htmxor/Http/HtmxResponse.cs
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);
}
}
63 changes: 63 additions & 0 deletions src/Htmxor/Http/Models/AjaxContext.cs
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; }
}

Loading

0 comments on commit 5404cb3

Please sign in to comment.