diff --git a/docs/articles/migrate-to-v2.md b/docs/articles/migrate-to-v2.md index cb1b04f..aaa56ef 100644 --- a/docs/articles/migrate-to-v2.md +++ b/docs/articles/migrate-to-v2.md @@ -466,7 +466,8 @@ These changes affect fewer applications: - `HtmxHeaderTagHelper.Headers` changed from `Dictionary` to `IDictionary`, 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 diff --git a/docs/articles/responses.md b/docs/articles/responses.md index 3684091..c97e3db 100644 --- a/docs/articles/responses.md +++ b/docs/articles/responses.md @@ -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: diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index 405fc2e..634e62b 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs @@ -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 . /// + /// + /// 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 + /// of the options that + /// belongs to. With a relaxed encoder, such as + /// , + /// these names must contain only ASCII characters. + /// /// public HtmxResponse TriggerEvent(string eventName, T detail, JsonTypeInfo jsonTypeInfo, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) => TriggerEventCore(this, eventName, detail, jsonTypeInfo, timing); @@ -373,8 +381,11 @@ private static string SerializeEventDetail(T detail, JsonTypeInfo 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(); - 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); diff --git a/src/Ramstack.HtmxToolkit/Internal/PendingEvents.cs b/src/Ramstack.HtmxToolkit/Internal/PendingEvents.cs index 8066698..7c1ba50 100644 --- a/src/Ramstack.HtmxToolkit/Internal/PendingEvents.cs +++ b/src/Ramstack.HtmxToolkit/Internal/PendingEvents.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.Options; using Ramstack.HtmxToolkit.Configuration; -using Ramstack.HtmxToolkit.Serialization; namespace Ramstack.HtmxToolkit.Internal; @@ -151,7 +150,9 @@ private void SetHeader(string name, EventList events) { var buffer = new ArrayBufferWriter(); - 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(); diff --git a/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonSerializerContext.cs index 21e2bc4..e2a0401 100644 --- a/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonSerializerContext.cs +++ b/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonSerializerContext.cs @@ -5,25 +5,14 @@ namespace Ramstack.HtmxToolkit.Serialization; /// /// Provides source-generated JSON serialization metadata for HX-Location options. /// +/// +/// The context uses the default encoder, which escapes non-ASCII characters, +/// because response headers allow only ASCII characters. +/// [JsonSourceGenerationOptions( WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, GenerationMode = JsonSourceGenerationMode.Default)] [JsonSerializable(typeof(HtmxLocationOptionsJsonView))] -internal partial class HtmxLocationOptionsJsonSerializerContext : JsonSerializerContext -{ - /// - /// Initializes the default serializer context with HTML-safe Unicode encoding. - /// - 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; diff --git a/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs b/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs index b740c2f..07043bf 100644 --- a/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs +++ b/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs @@ -11,36 +11,38 @@ namespace Ramstack.HtmxToolkit.Serialization; internal static class JsonOptions { /// - /// The shared encoder instance exposed by . + /// The shared encoder that preserves characters from all Unicode ranges while escaping + /// JavaScript- and HTML-sensitive characters. /// private static readonly JavaScriptEncoder s_encoder = JavaScriptEncoder.Create(new TextEncoderSettings(UnicodeRanges.All)); - /// - /// Gets the shared encoder that preserves characters from all Unicode ranges while escaping - /// JavaScript- and HTML-sensitive characters. - /// - public static JavaScriptEncoder Encoder => s_encoder; - - /// - /// Configures serializer options to preserve Unicode characters while escaping - /// JavaScript- and HTML-sensitive characters. - /// - /// The serializer options to configure. - public static void ConfigureHtmlSafeUnicode(JsonSerializerOptions options) => - options.Encoder = s_encoder; - /// /// The shared JSON serializer options that use /// for property names and dictionary keys and omit properties /// whose values are . /// + /// + /// The options use the default encoder, which escapes non-ASCII characters, + /// so that the resulting JSON can be written into a response header. + /// public static readonly JsonSerializerOptions CamelCase = new() { - Encoder = s_encoder, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; + + /// + /// Configures serializer options to preserve Unicode characters while escaping + /// JavaScript- and HTML-sensitive characters. + /// + /// The serializer options to configure. + /// + /// Use it only for JSON rendered into HTML attributes. Response headers allow + /// only ASCII characters and must use the default encoder. + /// + public static void ConfigureHtmlSafeUnicode(JsonSerializerOptions options) => + options.Encoder = s_encoder; } diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index c679cdc..7453d2e 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -1,3 +1,5 @@ +using System.Text.Encodings.Web; +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http; @@ -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 + { + ["имя"] = "Привет" + } + })); + + 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() { @@ -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() { diff --git a/tests/Ramstack.HtmxToolkit.Tests/Internal/PendingEventsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/Internal/PendingEventsTests.cs index 23dc6f8..0eede68 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/Internal/PendingEventsTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/Internal/PendingEventsTests.cs @@ -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() {