-
Notifications
You must be signed in to change notification settings - Fork 36
[DEVEX-222] Add built-in auto-serialization #329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f0e35bd
f8b77d2
8e27549
001f1a0
9ea2329
8ce4f42
cea88dc
4992546
1caba87
ef604ff
64c79e7
6db199d
e07b02a
786caf3
6446ea2
1e65b62
b395583
85bf401
edde73e
2bbb89b
c9f9305
d555cd0
1c1cb09
772de07
852e367
d414d06
861145e
391d212
e4f117c
0f2e42e
29034b0
723a2f6
51fe4b6
518499b
35c822d
a9cfa84
04e0685
4c73df6
8dca94a
68582da
5e4f2a5
78d0f22
e8385b3
f0a86fc
95f8546
62cebca
e938afe
9acba54
1af1def
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
namespace Kurrent.Client.Core.Serialization; | ||
|
||
/// <summary> | ||
/// Defines the core serialization capabilities required by the KurrentDB client. | ||
/// Implementations of this interface handle the conversion between .NET objects and their | ||
/// binary representation for storage in and retrieval from the event store. | ||
/// <br /> | ||
/// The client ships default System.Text.Json implementation, but custom implementations can be provided or other formats. | ||
/// </summary> | ||
public interface ISerializer { | ||
/// <summary> | ||
/// Converts a .NET object to its binary representation for storage in the event store. | ||
/// </summary> | ||
/// <param name="value">The object to serialize. This could be an event, command, or metadata object.</param> | ||
/// <returns> | ||
/// A binary representation of the object that can be stored in KurrentDB. | ||
/// </returns> | ||
public ReadOnlyMemory<byte> Serialize(object value); | ||
|
||
/// <summary> | ||
/// Reconstructs a .NET object from its binary representation retrieved from the event store. | ||
/// </summary> | ||
/// <param name="data">The binary data to deserialize, typically retrieved from a KurrentDB event.</param> | ||
/// <param name="type">The target .NET type to deserialize the data into, determined from message type mappings.</param> | ||
/// <returns> | ||
/// The deserialized object cast to the specified type, or null if the data cannot be deserialized. | ||
/// The returned object will be an instance of the specified type or a compatible subtype. | ||
/// </returns> | ||
public object? Deserialize(ReadOnlyMemory<byte> data, Type type); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
using EventStore.Client; | ||
|
||
namespace Kurrent.Client.Core.Serialization; | ||
|
||
/// <summary> | ||
/// Represents a message wrapper in the KurrentDB system, containing both domain data and optional metadata. | ||
/// Messages can represent events, commands, or other domain objects along with their associated metadata. | ||
/// </summary> | ||
/// <param name="Data">The message domain data.</param> | ||
/// <param name="Metadata">Optional metadata providing additional context about the message, such as correlation IDs, timestamps, or user information.</param> | ||
/// <param name="MessageId">Unique identifier for this specific message instance. When null, the system will auto-generate an ID.</param> | ||
public record Message(object Data, object? Metadata, Uuid? MessageId = null) { | ||
/// <summary> | ||
/// Creates a new Message with the specified domain data and message ID, but without metadata. | ||
/// This factory method is a convenient shorthand when working with systems that don't require metadata. | ||
/// </summary> | ||
/// <param name="data">The message domain data.</param> | ||
/// <param name="messageId">Unique identifier for this message instance. Must not be Uuid.Empty.</param> | ||
/// <returns>A new immutable Message instance containing the provided data and ID with null metadata.</returns> | ||
/// <example> | ||
/// <code> | ||
/// // Create a message with a specific ID | ||
/// var userCreated = new UserCreated { Id = "123", Name = "Alice" }; | ||
/// var messageId = Uuid.NewUuid(); | ||
/// var message = Message.From(userCreated, messageId); | ||
/// </code> | ||
/// </example> | ||
public static Message From(object data, Uuid messageId) => | ||
From(data, null, messageId); | ||
|
||
/// <summary> | ||
/// Creates a new Message with the specified domain data and message ID and metadata. | ||
/// </summary> | ||
/// <param name="data">The message domain data.</param> | ||
/// <param name="metadata">Optional metadata providing additional context about the message, such as correlation IDs, timestamps, or user information.</param> | ||
/// <param name="messageId">Unique identifier for this specific message instance. </param> | ||
/// <returns>A new immutable Message instance with the specified properties.</returns> | ||
/// <exception cref="ArgumentOutOfRangeException">Thrown when messageId is explicitly set to Uuid.Empty, which is an invalid identifier.</exception> | ||
/// <example> | ||
/// <code> | ||
/// // Create a message with data and metadata | ||
/// var orderPlaced = new OrderPlaced { OrderId = "ORD-123", Amount = 99.99m }; | ||
/// var metadata = new EventMetadata { | ||
/// UserId = "user-456", | ||
/// Timestamp = DateTimeOffset.UtcNow, | ||
/// CorrelationId = correlationId | ||
/// }; | ||
/// | ||
/// // Let the system assign an ID automatically | ||
/// var message = Message.From(orderPlaced, metadata); | ||
/// | ||
/// // Or specify a custom ID | ||
/// var messageWithId = Message.From(orderPlaced, metadata, Uuid.NewUuid()); | ||
/// </code> | ||
/// </example> | ||
public static Message From(object data, object? metadata = null, Uuid? messageId = null) { | ||
if (messageId == Uuid.Empty) | ||
throw new ArgumentOutOfRangeException(nameof(messageId), "Message ID cannot be an empty UUID."); | ||
|
||
return new Message(data, metadata, messageId); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
using System.Diagnostics.CodeAnalysis; | ||
using EventStore.Client; | ||
|
||
namespace Kurrent.Client.Core.Serialization; | ||
|
||
using static ContentTypeExtensions; | ||
|
||
interface IMessageSerializer { | ||
public EventData Serialize(Message value, MessageSerializationContext context); | ||
|
||
#if NET48 | ||
public bool TryDeserialize(EventRecord record, out Message? deserialized); | ||
#else | ||
public bool TryDeserialize(EventRecord record, [NotNullWhen(true)] out Message? deserialized); | ||
#endif | ||
} | ||
|
||
record MessageSerializationContext( | ||
string StreamName, | ||
ContentType ContentType | ||
) { | ||
public string CategoryName => | ||
StreamName.Split('-').FirstOrDefault() ?? "no_stream_category"; | ||
} | ||
|
||
static class MessageSerializerExtensions { | ||
public static EventData[] Serialize( | ||
this IMessageSerializer serializer, | ||
IEnumerable<Message> messages, | ||
MessageSerializationContext context | ||
) { | ||
return messages.Select(m => serializer.Serialize(m, context)).ToArray(); | ||
} | ||
|
||
public static IMessageSerializer With( | ||
this IMessageSerializer defaultMessageSerializer, | ||
KurrentClientSerializationSettings defaultSettings, | ||
OperationSerializationSettings? operationSettings | ||
) { | ||
if (operationSettings == null) | ||
return defaultMessageSerializer; | ||
|
||
if (operationSettings.AutomaticDeserialization == AutomaticDeserialization.Disabled) | ||
return NullMessageSerializer.Instance; | ||
|
||
if (operationSettings.ConfigureSettings == null) | ||
return defaultMessageSerializer; | ||
|
||
var settings = defaultSettings.Clone(); | ||
operationSettings.ConfigureSettings.Invoke(settings); | ||
|
||
return new MessageSerializer(SchemaRegistry.From(settings)); | ||
} | ||
} | ||
|
||
class MessageSerializer(SchemaRegistry schemaRegistry) : IMessageSerializer { | ||
readonly ISerializer _jsonSerializer = | ||
schemaRegistry.GetSerializer(ContentType.Json); | ||
|
||
readonly IMessageTypeNamingStrategy _messageTypeNamingStrategy = | ||
schemaRegistry.MessageTypeNamingStrategy; | ||
|
||
public EventData Serialize(Message message, MessageSerializationContext serializationContext) { | ||
var (data, metadata, eventId) = message; | ||
|
||
var eventType = _messageTypeNamingStrategy | ||
.ResolveTypeName( | ||
message.Data.GetType(), | ||
new MessageTypeNamingResolutionContext(serializationContext.CategoryName) | ||
); | ||
|
||
var serializedData = schemaRegistry | ||
.GetSerializer(serializationContext.ContentType) | ||
.Serialize(data); | ||
|
||
var serializedMetadata = metadata != null | ||
? _jsonSerializer.Serialize(metadata) | ||
: ReadOnlyMemory<byte>.Empty; | ||
|
||
return new EventData( | ||
eventId ?? Uuid.NewUuid(), | ||
eventType, | ||
serializedData, | ||
serializedMetadata, | ||
serializationContext.ContentType.ToMessageContentType() | ||
); | ||
} | ||
|
||
#if NET48 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This pragma is not needed if the project is compiled with latest SDK |
||
public bool TryDeserialize(EventRecord record, out Message? deserialized) { | ||
#else | ||
public bool TryDeserialize(EventRecord record, [NotNullWhen(true)] out Message? deserialized) { | ||
#endif | ||
if (!TryResolveClrType(record, out var clrType)) { | ||
deserialized = null; | ||
return false; | ||
} | ||
|
||
var data = schemaRegistry | ||
.GetSerializer(FromMessageContentType(record.ContentType)) | ||
.Deserialize(record.Data, clrType!); | ||
|
||
if (data == null) { | ||
deserialized = null; | ||
return false; | ||
} | ||
|
||
object? metadata = record.Metadata.Length > 0 && TryResolveClrMetadataType(record, out var clrMetadataType) | ||
? _jsonSerializer.Deserialize(record.Metadata, clrMetadataType!) | ||
: null; | ||
|
||
deserialized = Message.From(data, metadata, record.EventId); | ||
return true; | ||
} | ||
|
||
public static MessageSerializer From(KurrentClientSerializationSettings? settings = null) { | ||
settings ??= KurrentClientSerializationSettings.Default(); | ||
|
||
return new MessageSerializer(SchemaRegistry.From(settings)); | ||
} | ||
|
||
bool TryResolveClrType(EventRecord record, out Type? clrType) => | ||
schemaRegistry | ||
.MessageTypeNamingStrategy | ||
.TryResolveClrType(record.EventType, out clrType); | ||
|
||
bool TryResolveClrMetadataType(EventRecord record, out Type? clrMetadataType) => | ||
schemaRegistry | ||
.MessageTypeNamingStrategy | ||
.TryResolveClrMetadataType(record.EventType, out clrMetadataType); | ||
} | ||
|
||
class NullMessageSerializer : IMessageSerializer { | ||
public static readonly NullMessageSerializer Instance = new NullMessageSerializer(); | ||
|
||
public EventData Serialize(Message value, MessageSerializationContext context) { | ||
throw new InvalidOperationException("Cannot serialize, automatic deserialization is disabled"); | ||
} | ||
|
||
#if NET48 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This pragma is not needed if the project is compiled with latest SDK There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, based on my findings, and NetStandard 2.1 doesn't support .NET Framework, per: https://learn.microsoft.com/en-us/dotnet/standard/net-standard?tabs=net-standard-2-1. |
||
public bool TryDeserialize(EventRecord record, out Message? deserialized) { | ||
#else | ||
public bool TryDeserialize(EventRecord eventRecord, [NotNullWhen(true)] out Message? deserialized) { | ||
#endif | ||
deserialized = null; | ||
return false; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This pragma is not needed if the project is compiled with latest SDK