Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/articles/migrate-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ step; see [Version compatibility](version-compatibility.md) for that.
| [HX-Location](#replace-ajaxcontext-with-htmxlocationoptions) | `AjaxContext` becomes `HtmxLocationOptions` | Build |
| [Client events](#update-client-events) | Event names and detail dictionary keys keep their case; repeated names are kept | Build, **silent** |
| [Typed response headers](#update-typed-response-headers) | `HtmxResponseHeaders` is a struct; event properties are read-only | Build |
| [`[HtmxResponse]`](#update-htmxresponse) | Restricted to classes and methods, one per target, inherited | Build, **silent** |
| [`[HtmxResponse]`](#update-htmxresponse) | Restricted to classes and methods, one per target, inherited; code overrides it | Build, **silent** |

## Update the package

Expand Down Expand Up @@ -459,6 +459,9 @@ Other changes:
- Merge several `[HtmxResponse]` attributes on one target into one. Duplicates and other targets are now build errors.
- An attribute on a base controller or page model now applies to derived classes. In 1.x, it was ignored there.
An attribute on a derived class replaces the inherited one.
- The attribute sets its headers before the action or page handler runs, so values set in code take precedence.
In 1.x, it set them before the result executed and replaced the values that the action had set,
for example with `Response.Htmx(htmx => htmx.Retarget(...))`. Review actions that rely on the attribute winning.
- `StopPolling` was removed; see [Replace StopPolling](#replace-stoppolling).

## Other changes
Expand Down
4 changes: 4 additions & 0 deletions docs/articles/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@ When a controller and its action specify the same header, the action's value tak
A derived class inherits the attribute from its base class; an attribute declared on the derived class
replaces the inherited one entirely.

The attribute sets its headers before the action or page handler runs, so it works as a default.
The code can replace a header with `Response.Htmx(...)` or remove it through `Response.GetHtmxHeaders()`,
for example to retarget validation errors.

## Access headers directly

Use `Response.GetHtmxHeaders()` when middleware or shared infrastructure needs property access instead of fluent chaining:
Expand Down
50 changes: 37 additions & 13 deletions src/Ramstack.HtmxToolkit/HtmxResponseAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;

using Ramstack.HtmxToolkit.Internal;

namespace Ramstack.HtmxToolkit;

/// <summary>
/// Specifies HTMX response headers to apply when an action result is executed for an HTMX request.
/// Specifies HTMX response headers to apply to the response to an HTMX request
/// before the action or page handler runs.
/// </summary>
/// <remarks>
/// <para>
/// A property that is not set, or is set to <see langword="false" />, <see langword="null" />,
/// or an empty string, adds no header. The specified headers replace any values that the action
/// has already set for them.
/// or an empty string, adds no header. Because the headers are set before the action or page handler runs,
/// the code can replace or remove them, for example with
/// <see cref="HttpResponseExtensions.Htmx(HttpResponse, Action{HtmxResponse})" />.
/// If another filter short-circuits the action or page handler, the headers are not set.
/// </para>
/// <para>
/// The attribute can be applied to an action, a controller, or a Razor Pages page model.
Expand All @@ -28,7 +32,7 @@ namespace Ramstack.HtmxToolkit;
/// </para>
/// </remarks>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = false)]
public sealed class HtmxResponseAttribute : Attribute, IResultFilter
public sealed class HtmxResponseAttribute : Attribute, IActionFilter, IPageFilter
{
private readonly List<(string Key, string Value)> _headers = [];

Expand Down Expand Up @@ -94,22 +98,42 @@ public string Reselect
}

/// <inheritdoc />
public void OnResultExecuting(ResultExecutingContext context)
public void OnActionExecuting(ActionExecutingContext context) =>
ApplyHeaders(context.HttpContext);

/// <inheritdoc />
public void OnActionExecuted(ActionExecutedContext context)
{
if (context.HttpContext.Request.IsHtmxRequest())
{
var response = context.HttpContext.Response;
var headers = response.Headers;
foreach (ref var kvp in CollectionsMarshal.AsSpan(_headers))
headers[kvp.Key] = kvp.Value;
}
}

/// <inheritdoc />
public void OnResultExecuted(ResultExecutedContext context)
public void OnPageHandlerSelected(PageHandlerSelectedContext context)
{
}

/// <inheritdoc />
public void OnPageHandlerExecuting(PageHandlerExecutingContext context) =>
ApplyHeaders(context.HttpContext);

/// <inheritdoc />
public void OnPageHandlerExecuted(PageHandlerExecutedContext context)
{
}

/// <summary>
/// Sets the specified headers if the request is an HTMX request.
/// </summary>
/// <param name="context">The HTTP context of the request.</param>
private void ApplyHeaders(HttpContext context)
{
if (context.Request.IsHtmxRequest())
{
var headers = context.Response.Headers;
foreach (ref var kvp in CollectionsMarshal.AsSpan(_headers))
headers[kvp.Key] = kvp.Value;
}
}

/// <summary>
/// Gets the pending value of the specified response header.
/// </summary>
Expand Down
40 changes: 31 additions & 9 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxResponseAttributeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Routing;

namespace Ramstack.HtmxToolkit.Tests;
Expand All @@ -10,7 +11,7 @@ namespace Ramstack.HtmxToolkit.Tests;
public class HtmxResponseAttributeTests
{
[Test]
public void OnResultExecuting_SetsHeaders_ForHtmxRequest()
public void OnActionExecuting_SetsHeaders_ForHtmxRequest()
{
var ctx = TestHelper.CreateHtmxRequestContext();
var res = new HtmxResponseAttribute
Expand All @@ -21,7 +22,7 @@ public void OnResultExecuting_SetsHeaders_ForHtmxRequest()
Reselect = "#list"
};

res.OnResultExecuting(CreateContext(ctx));
res.OnActionExecuting(CreateActionContext(ctx));

Assert.That(ctx.Response.Headers[HtmxResponseHeaderNames.Refresh], Is.EqualTo("true"));
Assert.That(ctx.Response.Headers[HtmxResponseHeaderNames.Reswap], Is.EqualTo("outerHTML"));
Expand All @@ -30,25 +31,36 @@ public void OnResultExecuting_SetsHeaders_ForHtmxRequest()
}

[Test]
public void OnResultExecuting_SetsNoHeaders_ForNonHtmxRequest()
public void OnPageHandlerExecuting_SetsHeaders_ForHtmxRequest()
{
var ctx = TestHelper.CreateHtmxRequestContext();
var res = new HtmxResponseAttribute { Retarget = "#content" };

res.OnPageHandlerExecuting(CreatePageHandlerContext(ctx));

Assert.That(ctx.Response.Headers[HtmxResponseHeaderNames.Retarget], Is.EqualTo("#content"));
}

[Test]
public void OnActionExecuting_SetsNoHeaders_ForNonHtmxRequest()
{
var ctx = TestHelper.CreateHttpContext();
var res = new HtmxResponseAttribute { Retarget = "#content" };

res.OnResultExecuting(CreateContext(ctx));
res.OnActionExecuting(CreateActionContext(ctx));

Assert.That(ctx.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Retarget), Is.False);
}

[Test]
public void OnResultExecuting_SetsNoHeaders_ForHistoryRestoreRequest()
public void OnActionExecuting_SetsNoHeaders_ForHistoryRestoreRequest()
{
var ctx = TestHelper.CreateHttpContext(
(HtmxRequestHeaderNames.Request, "true"),
(HtmxRequestHeaderNames.HistoryRestoreRequest, "true"));
var res = new HtmxResponseAttribute { Retarget = "#content" };

res.OnResultExecuting(CreateContext(ctx));
res.OnActionExecuting(CreateActionContext(ctx));

Assert.That(ctx.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Retarget), Is.False);
}
Expand Down Expand Up @@ -79,12 +91,22 @@ public void ReswapExpression_RoundTrips()
Assert.That(res.ReswapExpression, Is.EqualTo("outerHTML ignoreTitle:true"));
}

private static ResultExecutingContext CreateContext(HttpContext context)
private static ActionExecutingContext CreateActionContext(HttpContext context)
{
return new ResultExecutingContext(
return new ActionExecutingContext(
new ActionContext(context, new RouteData(), new ActionDescriptor()),
new List<IFilterMetadata>(),
new EmptyResult(),
new Dictionary<string, object?>(),
new object());
}

private static PageHandlerExecutingContext CreatePageHandlerContext(HttpContext context)
{
return new PageHandlerExecutingContext(
new PageContext(new ActionContext(context, new RouteData(), new CompiledPageActionDescriptor())),
new List<IFilterMetadata>(),
handlerMethod: null,
new Dictionary<string, object?>(),
new object());
}
}
Loading