Skip to content

Commit 4ca133b

Browse files
committed
Add Creating a custom Serializer section
1 parent 49481ad commit 4ca133b

File tree

6 files changed

+243
-66
lines changed

6 files changed

+243
-66
lines changed

docs/client-concepts/serialization/custom-serialization.asciidoc

+99-7
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,14 @@ When serializing this class, rather than include a string value representing the
110110
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=converter-usings]
111111
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=customer-converter]
112112
----
113-
<1> When reading, this converter will read the `isStandard` boolean and translate this to the correct `CustomerType` enum value.
114-
<2> When writing, this converter will translate the `CustomerType` enum value to an `isStandard` boolean property.
113+
<1> When reading, this converter reads the `isStandard` boolean and translate this to the correct `CustomerType` enum value.
114+
<2> When writing, this converter translates the `CustomerType` enum value to an `isStandard` boolean property.
115115

116116
We can then index a customer document into {es}.
117117

118118
[source,csharp]
119119
----
120+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=usings]
120121
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=index-customer-with-converter]
121122
----
122123

@@ -130,9 +131,100 @@ The `Customer` instance is serialized using the custom converter, creating the f
130131
}
131132
----
132133

133-
[[injecting-custom-serializer]]
134-
===== Injecting a custom serializer
134+
[[creating-custom-system-text-json-serializer]]
135+
===== Creating a custom `SystemTextJsonSerializer`
136+
137+
The built-in `DefaultSourceSerializer` includes the registration of `JsonConverter` instances which apply during source serialization. In most cases, these provide the proper behavior for serializing source documents, including those which use `Elastic.Clients.Elasticsearch` types on their properties.
138+
139+
An example of a situation where you may require more control over the converter registration order is for serializing `enum` types. The `DefaultSourceSerializer` registers the `System.Text.Json.Serialization.JsonStringEnumConverter`, so enum values are serialized using their string representation. Generally, this is the preferred option for types used to index documents to {es}.
140+
141+
In some scenarios, you may need to control the string value sent for an enumeration value. That is not directly supported in `System.Text.Json` but can be achieved by creating a custom `JsonConverter` for the `enum` type you wish to customize. In this situation, it is not sufficient to use the `JsonConverterAttribute` on the `enum` type to register the converter. `System.Text.Json` will prefer the converters added to the `Converters` collection on the `JsonSerializerOptions` over an attribute applied to an `enum` type. It is, therefore, necessary to either remove the `JsonStringEnumConverter` from the `Converters` collection or register a specialized converter for your `enum` type before the `JsonStringEnumConverter`.
142+
143+
The latter is possible via several techniques. When using the {es} .NET library, we can achieve this by deriving from the abstract `SystemTextJsonSerializer` class.
144+
145+
Here we have a POCO which uses the `CustomerType` enum as the type for a property.
146+
147+
[source,csharp]
148+
----
149+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=usings-serialization]
150+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=customer-without-jsonconverter-attribute]
151+
----
152+
153+
To customize the strings that are used during serialization of the `CustomerType` we defined a custom `JsonConverter` specific to our `enum` type.
154+
155+
[source,csharp]
156+
----
157+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=usings-serialization]
158+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=customer-type-converter]
159+
----
160+
<1> When reading, this converter translates the string used in the JSON to the matching enum value.
161+
<2> When writing, this converter translates the `CustomerType` enum value to an custom string value written to the JSON.
162+
163+
We can create a serializer which derives from `SystemTextJsonSerializer` to give us full control of converter registration.
164+
165+
[source,csharp]
166+
----
167+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=derived-converter-usings]
168+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=my-custom-serializer]
169+
----
170+
<1> Inherit from `SystemTextJsonSerializer`.
171+
<2> In the constructor, use the factory method `DefaultSourceSerializer.CreateDefaultJsonSerializerOptions` to create default options for serialization. By passing `false`, no default converters are registered at this stage.
172+
<3> Register our `CustomerTypeConverter` as the first converter.
173+
<4> To apply any default converters call the `DefaultSourceSerializer.AddDefaultConverters` helper method, passing the options to modify.
174+
<5> Implement the `CreateJsonSerializerOptions` method returning the stored `JsonSerializerOptions`.
175+
176+
Because we have registered our `CustomerTypeConverter` before the default converters (which include the `JsonStringEnumConverter`), our converter takes precedence when serializing `CustomerType` instances on source documents.
177+
178+
The base `SystemTextJsonSerializer` class handles the implementation details of binding, required to ensure that the built-in converters can access the `IElasticsearchClientSettings` where needed.
179+
180+
We can then index a customer document into {es}.
181+
182+
[source,csharp]
183+
----
184+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=usings]
185+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=index-customer-without-jsonconverter-attribute]
186+
----
187+
188+
The `Customer` instance is serialized using the custom `enum` converter, creating the following JSON document.
189+
190+
[source,javascript]
191+
----
192+
{
193+
"customerName": "Customer Ltd",
194+
"customerType": "premium" // <1>
195+
}
196+
----
197+
<1> The string value applied during serialization is provided by our custom converter.
198+
199+
[[creating-custom-serializers]]
200+
===== Creating a custom `Serializer`
201+
202+
If you prefer to use an alternative JSON serialization library for your source types, you can inject a serializer that is isolated to only be called for the serialization of `_source`, `_fields`, or wherever a user provided value is expected to be written and returned.
203+
204+
Implementing `Elastic.Transport.Serializer` is technically enough to inject your own source serializer.
205+
206+
[source,csharp]
207+
----
208+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=vanilla-serializer-using-directives]
209+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=vanilla-serializer]
210+
----
211+
212+
Registering up the serializer is performed in the `ConnectionSettings` constructor
213+
214+
[source,csharp]
215+
----
216+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=usings]
217+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=register-vanilla-serializer]
218+
----
219+
<1> If implementing `Serializer` is enough, why do we need to provide an instance wrapped in a factory `Func`?
220+
221+
There are various cases where you might have a POCO type that contains an `Elastic.Clients.Elasticsearch` type as one of its properties. The `SourceSerializerFactory` delegate provides access to the default built-in serializer so that you can access it when necessary. For example, consider if you want to use percolation; you need to store {es} queries as part of the `_source` of your document, which means you need to have a POCO that looks like this.
222+
223+
[source,csharp]
224+
----
225+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=querydsl-using-directives]
226+
include::{doc-tests-src}/ClientConcepts/Serialization/CustomSerializationTests.cs[tag=percolation-document]
227+
----
135228

136-
TODO
137-
- Deriving from SystemTextJsonSerializer for enum Converter
138-
- Deriving from Serializer for different library
229+
A custom serializer would not know how to serialize `Query` or other `Elastic.Clients.Elasticsearch` types that could appear as part of
230+
the `_source` of a document. Therefore, your custom `Serializer` would need to store a reference to our built-in serializer, and delegate serialization of Elastic types back to it.

src/Elastic.Clients.Elasticsearch/Serialization/DefaultSourceSerializer.cs

+1-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using System;
66
using System.Text.Json;
77
using System.Text.Json.Serialization;
8-
using Elastic.Clients.Elasticsearch.QueryDsl;
98

109
namespace Elastic.Clients.Elasticsearch.Serialization;
1110

@@ -21,8 +20,7 @@ public class DefaultSourceSerializer : SystemTextJsonSerializer
2120
/// </summary>
2221
public static JsonConverter[] DefaultBuiltInConverters => new JsonConverter[]
2322
{
24-
new JsonStringEnumConverter(),
25-
new QueryConverter()
23+
new JsonStringEnumConverter()
2624
};
2725

2826
private readonly JsonSerializerOptions _jsonSerializerOptions;

tests/Tests/Documentation/ClientConcepts/Serialization/CustomSerializationTests.cs

+138-52
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information.
44

5+
// **********************************
56
// IMPORTANT: These tests have a secondary use as code snippets used in documentation.
67
// We disable formatting in sections of this file to ensure the correct indentation when tagged regions are
78
// included in the asciidocs. While hard to read, this formatting should be left as-is for docs generation.
89
// We also include using directives that are not required due to global using directives, but remain here
910
// so that can appear in the documentation.
11+
// **********************************
1012

11-
#pragma warning disable IDE0005
13+
#pragma warning disable CS0105 // Using directive appeared previously in this namespace
14+
#pragma warning disable IDE0005 // Remove unnecessary using directives
1215
//tag::usings[]
1316
//tag::converter-usings[]
1417
using System;
@@ -22,10 +25,26 @@
2225
using Elastic.Clients.Elasticsearch;
2326
using Elastic.Clients.Elasticsearch.Serialization;
2427
//end::usings[]
28+
//tag::derived-converter-usings[]
29+
using System.Text.Json;
30+
using Elastic.Clients.Elasticsearch.Serialization;
31+
//end::derived-converter-usings[]
32+
//tag::vanilla-serializer-using-directives[]
33+
using System;
34+
using System.IO;
35+
using System.Threading;
36+
using System.Threading.Tasks;
37+
using Elastic.Transport;
38+
//end::vanilla-serializer-using-directives[]
2539
using System.Text;
2640
using VerifyXunit;
2741
using System.IO;
28-
#pragma warning restore IDE0005
42+
using System.Threading;
43+
//tag::querydsl-using-directives[]
44+
using Elastic.Clients.Elasticsearch.QueryDsl;
45+
//end::querydsl-using-directives[]
46+
#pragma warning restore IDE0005 // Remove unnecessary using directives
47+
#pragma warning restore CS0105 // Using directive appeared previously in this namespace
2948

3049
namespace Tests.Documentation.Serialization;
3150

@@ -67,7 +86,8 @@ static void ConfigureOptions(JsonSerializerOptions o) => // <1>
6786
#pragma warning disable format
6887
//tag::index-person[]
6988
var person = new Person { FirstName = "Steve" };
70-
var indexResponse = await client.IndexAsync(person, "my-index-name"); //end::index-person[]
89+
var indexResponse = await client.IndexAsync(person, "my-index-name");
90+
//end::index-person[]
7191
#pragma warning restore format
7292

7393
var requestJson = Encoding.UTF8.GetString(indexResponse.ApiCallDetails.RequestBodyInBytes);
@@ -297,77 +317,143 @@ public async Task DerivingFromSystemTextJsonSerializer_ToRegisterACustomEnumConv
297317
.DisableDirectStreaming();
298318
client = new ElasticsearchClient(settings);
299319

300-
var customer = new Customer
301-
{
302-
CustomerName = "Customer Ltd",
303-
Type = CustomerType.Enhanced
304-
};
320+
#pragma warning disable format
321+
//tag::index-customer-without-jsonconverter-attribute[]
322+
var customer = new Customer
323+
{
324+
CustomerName = "Customer Ltd",
325+
CustomerType = CustomerType.Enhanced
326+
};
305327

306-
var indexResponse = await client.IndexAsync(customer, "my-index-name");
328+
var indexResponse = await client.IndexAsync(customer, "my-index-name");
329+
//end::index-customer-without-jsonconverter-attribute[]
330+
#pragma warning restore format
307331

308332
var requestJson = Encoding.UTF8.GetString(indexResponse.ApiCallDetails.RequestBodyInBytes);
309333
await Verifier.Verify(requestJson);
310334

311335
var ms = new MemoryStream(indexResponse.ApiCallDetails.RequestBodyInBytes);
312336
var deserializedCustomer = client.SourceSerializer.Deserialize<Customer>(ms);
313337
deserializedCustomer.CustomerName.Should().Be("Customer Ltd");
314-
deserializedCustomer.Type.Should().Be(CustomerType.Enhanced);
338+
deserializedCustomer.CustomerType.Should().Be(CustomerType.Enhanced);
315339
}
316340

317-
public class Customer
318-
{
319-
public string CustomerName { get; set; }
320-
public CustomerType Type { get; set; }
321-
}
341+
#pragma warning disable format
342+
//tag::customer-without-jsonconverter-attribute[]
343+
public class Customer
344+
{
345+
public string CustomerName { get; set; }
346+
public CustomerType CustomerType { get; set; }
347+
}
322348

323-
public enum CustomerType
324-
{
325-
Standard,
326-
Enhanced
327-
}
349+
public enum CustomerType
350+
{
351+
Standard,
352+
Enhanced
353+
}
354+
//end::customer-without-jsonconverter-attribute[]
355+
#pragma warning restore format
356+
357+
#pragma warning disable format
358+
//tag::my-custom-serializer[]
359+
public class MyCustomSerializer : SystemTextJsonSerializer // <1>
360+
{
361+
private readonly JsonSerializerOptions _options;
328362

329-
public class MyCustomSerializer : SystemTextJsonSerializer
363+
public MyCustomSerializer(IElasticsearchClientSettings settings) : base(settings)
330364
{
331-
private readonly JsonSerializerOptions _options;
365+
var options = DefaultSourceSerializer.CreateDefaultJsonSerializerOptions(false); // <2>
332366

333-
public MyCustomSerializer(IElasticsearchClientSettings settings) : base(settings)
334-
{
335-
var options = DefaultSourceSerializer.CreateDefaultJsonSerializerOptions(false);
367+
options.Converters.Add(new CustomerTypeConverter()); // <3>
336368

337-
options.Converters.Add(new CustomerTypeConverter());
369+
_options = DefaultSourceSerializer.AddDefaultConverters(options); // <4>
370+
}
338371

339-
_options = DefaultSourceSerializer.AddDefaultConverters(options);
340-
}
372+
protected override JsonSerializerOptions CreateJsonSerializerOptions() => _options; // <5>
373+
}
374+
//end::my-custom-serializer[]
375+
#pragma warning restore format
341376

342-
protected override JsonSerializerOptions CreateJsonSerializerOptions() => _options;
377+
#pragma warning disable format
378+
//tag::customer-type-converter[]
379+
public class CustomerTypeConverter : JsonConverter<CustomerType>
380+
{
381+
public override CustomerType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
382+
{
383+
return reader.GetString() switch // <1>
384+
{
385+
"basic" => CustomerType.Standard,
386+
"premium" => CustomerType.Enhanced,
387+
_ => throw new JsonException(
388+
$"Unknown value read when deserializing {nameof(CustomerType)}."),
389+
};
343390
}
344391

345-
public class CustomerTypeConverter : JsonConverter<CustomerType>
392+
public override void Write(Utf8JsonWriter writer, CustomerType value, JsonSerializerOptions options)
346393
{
347-
public override CustomerType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
394+
switch (value) // <2>
348395
{
349-
return reader.GetString() switch
350-
{
351-
"basic" => CustomerType.Standard,
352-
"premium" => CustomerType.Enhanced,
353-
_ => throw new JsonException(
354-
$"Unknown value read when deserializing {nameof(CustomerType)}."),
355-
};
396+
case CustomerType.Standard:
397+
writer.WriteStringValue("basic");
398+
return;
399+
case CustomerType.Enhanced:
400+
writer.WriteStringValue("premium");
401+
return;
356402
}
357403

358-
public override void Write(Utf8JsonWriter writer, CustomerType value, JsonSerializerOptions options)
359-
{
360-
switch (value)
361-
{
362-
case CustomerType.Standard:
363-
writer.WriteStringValue("basic");
364-
return;
365-
case CustomerType.Enhanced:
366-
writer.WriteStringValue("premium");
367-
return;
368-
}
369-
370-
writer.WriteNullValue();
371-
}
404+
writer.WriteNullValue();
372405
}
373406
}
407+
//end::customer-type-converter[]
408+
#pragma warning restore format
409+
410+
public void RegisterVanillaSerializer()
411+
{
412+
#pragma warning disable format
413+
//tag::register-vanilla-serializer[]
414+
var nodePool = new SingleNodePool(new Uri("http://localhost:9200"));
415+
var settings = new ElasticsearchClientSettings(
416+
nodePool,
417+
sourceSerializer: (defaultSerializer, settings) =>
418+
new VanillaSerializer()); // <1>
419+
var client = new ElasticsearchClient(settings);
420+
//end::register-vanilla-serializer[]
421+
#pragma warning restore format
422+
}
423+
424+
#pragma warning disable format
425+
//tag::vanilla-serializer[]
426+
public class VanillaSerializer : Serializer
427+
{
428+
public override object Deserialize(Type type, Stream stream) =>
429+
throw new NotImplementedException();
430+
431+
public override T Deserialize<T>(Stream stream) =>
432+
throw new NotImplementedException();
433+
434+
public override ValueTask<object> DeserializeAsync(Type type, Stream stream, CancellationToken cancellationToken = default) =>
435+
throw new NotImplementedException();
436+
437+
public override ValueTask<T> DeserializeAsync<T>(Stream stream, CancellationToken cancellationToken = default) =>
438+
throw new NotImplementedException();
439+
440+
public override void Serialize<T>(T data, Stream stream, SerializationFormatting formatting = SerializationFormatting.None) =>
441+
throw new NotImplementedException();
442+
443+
public override Task SerializeAsync<T>(T data, Stream stream,
444+
SerializationFormatting formatting = SerializationFormatting.None, CancellationToken cancellationToken = default) =>
445+
throw new NotImplementedException();
446+
}
447+
//end::vanilla-serializer[]
448+
#pragma warning restore format
449+
450+
#pragma warning disable format
451+
//tag::percolation-document[]
452+
public class MyPercolationDocument
453+
{
454+
public Query Query { get; set; }
455+
public string Category { get; set; }
456+
}
457+
//end::percolation-document[]
458+
#pragma warning restore format
459+
}

0 commit comments

Comments
 (0)