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
3 changes: 2 additions & 1 deletion docs/articles/migrate-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,8 @@ These changes affect fewer applications:
- `HtmxHeaderTagHelper.Headers` changed from `Dictionary<string, string>` to `IDictionary<string, string>`,
and header names are compared without case.
- `HtmxScrollBehavior` gained `Instant`.
- Generated JSON in attributes and headers is encoded differently. Update snapshot tests that compare it as text.
- Generated JSON in attributes keeps non-ASCII characters, such as Cyrillic, instead of escaping them as `\uXXXX`.
Update snapshot tests that compare it as text.

## New in 2.0

Expand Down
7 changes: 7 additions & 0 deletions docs/articles/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,13 @@ Response.Htmx(

The JSON naming policy keeps the payload the same, so the JavaScript listener above still reads `event.detail.id`.

Response headers allow only ASCII characters. Other characters in string values and dictionary keys
are escaped as `\uXXXX`, but property names and enum names are written as the encoder of the metadata
options escaped them. `AppJsonContext.Default` uses the default encoder, which escapes these names as well.
If you take the metadata from options with a relaxed encoder, such as Minimal API `JsonOptions`,
which use `JavaScriptEncoder.UnsafeRelaxedJsonEscaping`, keep these names ASCII-only;
otherwise, the server rejects the header.

## Configure declaratively in MVC

`HtmxResponseAttribute` is convenient when response behavior is constant:
Expand Down
13 changes: 12 additions & 1 deletion src/Ramstack.HtmxToolkit/HtmxResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,14 @@ static HtmxResponse TriggerEventImpl(HtmxResponse response, string eventName, ob
/// Like any other response header, an event cannot be added after the response has started;
/// the method then throws <see cref="InvalidOperationException" />.
/// </para>
/// <para>
/// Response headers allow only ASCII characters. Other characters in string values and dictionary keys
/// are escaped, but property names and enum names are written as escaped by the
/// <see cref="JsonSerializerOptions.Encoder" /> of the options that <paramref name="jsonTypeInfo" />
/// belongs to. With a relaxed encoder, such as
/// <see cref="System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping" />,
/// these names must contain only ASCII characters.
/// </para>
/// </remarks>
public HtmxResponse TriggerEvent<T>(string eventName, T detail, JsonTypeInfo<T> jsonTypeInfo, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) =>
TriggerEventCore(this, eventName, detail, jsonTypeInfo, timing);
Expand Down Expand Up @@ -373,8 +381,11 @@ private static string SerializeEventDetail<T>(T detail, JsonTypeInfo<T> jsonType
{
Argument.ThrowIfNull(jsonTypeInfo);

// The writer's default encoder escapes non-ASCII characters, which response headers do not allow,
// in string values and dictionary keys even if the jsonTypeInfo options use a relaxed encoder.
// Property names and enum names are written as escaped by the encoder of those options.
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Encoder = JsonOptions.Encoder, SkipValidation = true }))
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { SkipValidation = true }))
JsonSerializer.Serialize(writer, detail, jsonTypeInfo);

return Encoding.UTF8.GetString(buffer.WrittenSpan);
Expand Down
5 changes: 3 additions & 2 deletions src/Ramstack.HtmxToolkit/Internal/PendingEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using Microsoft.Extensions.Options;

using Ramstack.HtmxToolkit.Configuration;
using Ramstack.HtmxToolkit.Serialization;

namespace Ramstack.HtmxToolkit.Internal;

Expand Down Expand Up @@ -151,7 +150,9 @@ private void SetHeader(string name, EventList events)
{
var buffer = new ArrayBufferWriter<byte>();

using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Encoder = JsonOptions.Encoder, SkipValidation = true }))
// The default encoder escapes non-ASCII characters in event names,
// which response headers do not allow.
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { SkipValidation = true }))
{
writer.WriteStartObject();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,14 @@ namespace Ramstack.HtmxToolkit.Serialization;
/// <summary>
/// Provides source-generated JSON serialization metadata for <c>HX-Location</c> options.
/// </summary>
/// <remarks>
/// The context uses the default encoder, which escapes non-ASCII characters,
/// because response headers allow only ASCII characters.
/// </remarks>
[JsonSourceGenerationOptions(
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
GenerationMode = JsonSourceGenerationMode.Default)]
[JsonSerializable(typeof(HtmxLocationOptionsJsonView))]
internal partial class HtmxLocationOptionsJsonSerializerContext : JsonSerializerContext
{
/// <summary>
/// Initializes the default serializer context with HTML-safe Unicode encoding.
/// </summary>
static HtmxLocationOptionsJsonSerializerContext()
{
JsonOptions.ConfigureHtmlSafeUnicode(s_defaultOptions);

#if NET8_0_OR_GREATER
Default = new HtmxLocationOptionsJsonSerializerContext(s_defaultOptions);
#else
s_defaultContext = new HtmxLocationOptionsJsonSerializerContext(s_defaultOptions);
#endif
}
}
internal partial class HtmxLocationOptionsJsonSerializerContext : JsonSerializerContext;
34 changes: 18 additions & 16 deletions src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,38 @@ namespace Ramstack.HtmxToolkit.Serialization;
internal static class JsonOptions
{
/// <summary>
/// The shared encoder instance exposed by <see cref="Encoder" />.
/// The shared encoder that preserves characters from all Unicode ranges while escaping
/// JavaScript- and HTML-sensitive characters.
/// </summary>
private static readonly JavaScriptEncoder s_encoder =
JavaScriptEncoder.Create(new TextEncoderSettings(UnicodeRanges.All));

/// <summary>
/// Gets the shared encoder that preserves characters from all Unicode ranges while escaping
/// JavaScript- and HTML-sensitive characters.
/// </summary>
public static JavaScriptEncoder Encoder => s_encoder;

/// <summary>
/// Configures serializer options to preserve Unicode characters while escaping
/// JavaScript- and HTML-sensitive characters.
/// </summary>
/// <param name="options">The serializer options to configure.</param>
public static void ConfigureHtmlSafeUnicode(JsonSerializerOptions options) =>
options.Encoder = s_encoder;

/// <summary>
/// The shared JSON serializer options that use <see cref="JsonNamingPolicy.CamelCase" />
/// for property names and dictionary keys and omit properties
/// whose values are <see langword="null" />.
/// </summary>
/// <remarks>
/// The options use the default encoder, which escapes non-ASCII characters,
/// so that the resulting JSON can be written into a response header.
/// </remarks>
public static readonly JsonSerializerOptions CamelCase = new()
{
Encoder = s_encoder,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};

/// <summary>
/// Configures serializer options to preserve Unicode characters while escaping
/// JavaScript- and HTML-sensitive characters.
/// </summary>
/// <param name="options">The serializer options to configure.</param>
/// <remarks>
/// Use it only for JSON rendered into HTML attributes. Response headers allow
/// only ASCII characters and must use the default encoder.
/// </remarks>
public static void ConfigureHtmlSafeUnicode(JsonSerializerOptions options) =>
options.Encoder = s_encoder;
}
54 changes: 54 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;

using Microsoft.AspNetCore.Http;
Expand Down Expand Up @@ -142,6 +144,26 @@ public void Location_WithOptions_SerializesValuesAndHeaders()
Assert.That(json["headers"].GetProperty("X-Test").GetString(), Is.EqualTo("abc"));
}

[Test]
public void Location_WithNonAsciiOptions_WritesAsciiHeader()
{
// Kestrel rejects non-ASCII characters in response header values.
var context = TestHelper.CreateHtmxRequestContext();
context.Response.Htmx(r => r.Location("/bar", new HtmxLocationOptions
{
Values = new Dictionary<string, HtmxFieldValues>
{
["имя"] = "Привет"
}
}));

var header = context.Response.Headers[HtmxResponseHeaderNames.Location].ToString();
var json = JsonHelper.ParseJson(header);

Assert.That(header.All(char.IsAscii), Is.True);
Assert.That(json["values"].GetProperty("имя").GetString(), Is.EqualTo("Привет"));
}

[Test]
public void Location_WithOptions_SerializesHistoryAndOutOfBandOptions()
{
Expand Down Expand Up @@ -455,6 +477,38 @@ public void TriggerEvent_WithIndentedJsonTypeInfo_SerializesImmediatelyAsCompact
});
}

[Test]
public void TriggerEvent_WithNonAsciiDetail_EscapesNonAsciiCharacters()
{
// Kestrel rejects non-ASCII characters in response header values.
var context = TestHelper.CreateHtmxRequestContext();
context.Response.Htmx(r => r.TriggerEvent("saved", new { Name = "Привет" }));

Assert.That(
context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(),
Is.EqualTo("{\"saved\":{\"name\":\"\\u041F\\u0440\\u0438\\u0432\\u0435\\u0442\"}}"));
}

[Test]
public void TriggerEvent_WithRelaxedEscapingJsonTypeInfo_EscapesNonAsciiCharacters()
{
// Minimal API JsonOptions use UnsafeRelaxedJsonEscaping,
// so metadata taken from them must not let non-ASCII characters into the header.
var context = TestHelper.CreateHtmxRequestContext();
var options = new JsonSerializerOptions
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

var info = new TriggerEventJsonSerializerContext(options).TriggerEventDetail;
context.Response.Htmx(r => r.TriggerEvent("saved", new TriggerEventDetail { Text = "Привет" }, info));

Assert.That(
context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(),
Is.EqualTo("{\"saved\":{\"text\":\"\\u041F\\u0440\\u0438\\u0432\\u0435\\u0442\"}}"));
}

[Test]
public void TriggerEvent_NullObjectDetail_SerializesAsNullValue()
{
Expand Down
28 changes: 28 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/Internal/PendingEventsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,34 @@ public void AddEvent_PreservesEventNameCase_InReplayedEvents()
Is.EqualTo("{\"rs:event\":[{\"key\":\"ShowMessage\",\"value\":1},{\"key\":\"ShowMessage\",\"value\":2}]}"));
}

[Test]
public void AddEvent_EscapesNonAsciiEventName()
{
// Kestrel rejects non-ASCII characters in response header values.
var context = TestHelper.CreateHttpContext();
var pending = PendingEvents.GetOrCreate(context.Response);

pending.AddEvent(HtmxTriggerTiming.Receive, "сохранено", "1");

Assert.That(
context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(),
Is.EqualTo("{\"\\u0441\\u043E\\u0445\\u0440\\u0430\\u043D\\u0435\\u043D\\u043E\":1}"));
}

[Test]
public void AddEvent_EscapesNonAsciiEventName_InReplayedEvents()
{
var context = TestHelper.CreateHttpContext();
var pending = PendingEvents.GetOrCreate(context.Response);

pending.AddEvent(HtmxTriggerTiming.Receive, "ок", "1");
pending.AddEvent(HtmxTriggerTiming.Receive, "ок", "2");

Assert.That(
context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(),
Is.EqualTo("{\"rs:event\":[{\"key\":\"\\u043E\\u043A\",\"value\":1},{\"key\":\"\\u043E\\u043A\",\"value\":2}]}"));
}

[Test]
public void AddEvent_WritesEventNamesThatDifferOnlyInCaseSeparately()
{
Expand Down
Loading