Skip to content
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

Add JsonConverter generator #65

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,86 @@ Note that if you provide a `[Display]` or `[Description]` attribute, the value y

You can override the name of the extension class by setting `ExtensionClassName` in the attribute and/or the namespace of the class by setting `ExtensionClassNamespace`. By default, the class will be public if the enum is public, otherwise it will be internal.

If you want a `JsonConverter` that uses the generated extensions for efficient serialization and deserialization you can add the `EnumJsonConverter` and `JsonConverter` to the enum. For example:
```csharp
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;

[EnumExtensions]
[EnumJsonConverter(typeof(MyEnumConverter))]
[JsonConverter(typeof(MyEnumConverter))]
public enum MyEnum
{
First,
[Display(Name = "2nd")] Second
}
```

This will generate a class called `MyEnumConverter`. For example:
```csharp
/// <summary>
/// Converts a <see cref="global::MyEnum" /> to or from JSON.
/// </summary>
public sealed class MyEnumConverter : global::System.Text.Json.Serialization.JsonConverter<global::MyEnum>
{
/// <inheritdoc />
/// <summary>
/// Read and convert the JSON to <see cref="global::MyEnum" />.
/// </summary>
/// <remarks>
/// A converter may throw any Exception, but should throw <see cref="global::System.Text.Json.JsonException" /> when the JSON is invalid.
/// </remarks>
public override global::MyEnum Read(ref global::System.Text.Json.Utf8JsonReader reader, global::System.Type typeToConvert, global::System.Text.Json.JsonSerializerOptions options)
{
#if NETCOREAPP && !NETCOREAPP2_0 && !NETCOREAPP1_1 && !NETCOREAPP1_0
char[]? rentedBuffer = null;
var bufferLength = reader.HasValueSequence ? checked((int)reader.ValueSequence.Length) : reader.ValueSpan.Length;

var charBuffer = bufferLength <= 128
? stackalloc char[128]
: rentedBuffer = global::System.Buffers.ArrayPool<char>.Shared.Rent(bufferLength);

var charsWritten = reader.CopyString(charBuffer);
global::System.ReadOnlySpan<char> source = charBuffer[..charsWritten];
try
{
if (global::MyEnumExtensions.TryParse(source, out var enumValue, true, false))
return enumValue;

throw new global::System.Text.Json.JsonException($"{source.ToString()} is not a valid value.", null, null, null);
}
finally
{
if (rentedBuffer is not null)
{
charBuffer[..charsWritten].Clear();
global::System.Buffers.ArrayPool<char>.Shared.Return(rentedBuffer);
}
}
#else
var source = reader.GetString();
if (global::MyEnumExtensions.TryParse(source, out var enumValue, true, false))
return enumValue;

throw new global::System.Text.Json.JsonException($"{source} is not a valid value.", null, null, null);
#endif
}

/// <inheritdoc />
public override void Write(global::System.Text.Json.Utf8JsonWriter writer, global::MyEnum value, global::System.Text.Json.JsonSerializerOptions options)
=> writer.WriteStringValue(global::MyEnumExtensions.ToStringFast(value));
}
```

_Note: If you've added `JsonStringEnumConverter` to the `JsonSerializerOptions.Converters`, you must add the generated converters manually before adding the `JsonStringEnumConverter`._

You can customize the generated code for the converter by setting the following values:
- `CaseSensitive` - Indicates if the string representation is case sensitive when deserializing it as an enum.
- `CamelCase` - Indicates if the value of `PropertyName` should be camel cased.
- `AllowMatchingMetadataAttribute` - If `true`, considers the value of metadata attributes, otherwise ignores them.
- `PropertyName` - If set, this value will be used in messages when there are problems with validation and/or serialization/deserialization occurs.


## Embedding the attributes in your project

By default, the `[EnumExtensions]` attributes referenced in your application are contained in an external dll. It is also possible to embed the attributes directly in your project, so they appear in the dll when your project is built. If you wish to do this, you must do two things:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace NetEscapades.EnumGenerators
{
/// <summary>
/// Add to enums to indicate that a JsonConverter for the enum should be generated.
/// </summary>
[System.AttributeUsage(System.AttributeTargets.Enum)]
[System.Diagnostics.Conditional("NETESCAPADES_ENUMGENERATORS_USAGES")]
public sealed class EnumJsonConverterAttribute : System.Attribute
{
/// <summary>
/// The converter that should be generated.
/// </summary>
public System.Type ConverterType { get; }

/// <summary>
/// Indicates if the string representation is case sensitive when deserializing it as an enum.
/// </summary>
public bool CaseSensitive { get; set; }

/// <summary>
/// Indicates if the value of <see cref="PropertyName"/> should be camel cased.
/// </summary>
public bool CamelCase { get; set; }

/// <summary>
/// If <see langword="true" />, considers the value of metadata attributes, otherwise ignores them.
/// </summary>
public bool AllowMatchingMetadataAttribute { get; set; }

/// <summary>
/// If set, this value will be used in messages when there are problems with validation and/or serialization/deserialization occurs.
/// </summary>
public string? PropertyName { get; set; }

/// <summary>
/// Creates an instance of <see cref="EnumJsonConverterAttribute"/>.
/// </summary>
/// <param name="converterType">The converter to generate.</param>
public EnumJsonConverterAttribute(System.Type converterType) => ConverterType = converterType;
}
}
165 changes: 165 additions & 0 deletions src/NetEscapades.EnumGenerators/JsonConverterGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace NetEscapades.EnumGenerators
{
[Generator]
public class JsonConverterGenerator : IIncrementalGenerator
{
private const string EnumJsonConverterAttribute = "NetEscapades.EnumGenerators.EnumJsonConverterAttribute";
private const string EnumExtensionsAttribute = "NetEscapades.EnumGenerators.EnumExtensionsAttribute";

public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(static ctx => ctx.AddSource(
"EnumJsonConverterAttribute.g.cs",
SourceText.From(SourceGenerationHelper.JsonConverterAttribute, Encoding.UTF8)));

var jsonConvertersToGenerate = context.SyntaxProvider
.ForAttributeWithMetadataName(
EnumJsonConverterAttribute,
static (node, _) => node is EnumDeclarationSyntax,
GetTypeToGenerate)
.Where(static m => m is not null);

context.RegisterSourceOutput(jsonConvertersToGenerate,
static (spc, source) => Execute(source, spc));
}

private static void Execute(JsonConverterToGenerate? jsonConverterToGenerate, SourceProductionContext context)
{
if (jsonConverterToGenerate is { } eg)
{
StringBuilder sb = new();
var result = SourceGenerationHelper.GenerateJsonConverterClass(sb, eg);
context.AddSource(eg.ConverterType + ".g.cs", SourceText.From(result, Encoding.UTF8));
}
}

private static JsonConverterToGenerate? GetTypeToGenerate(GeneratorAttributeSyntaxContext context,
CancellationToken ct)
{
if (context.TargetSymbol is not INamedTypeSymbol enumSymbol)
{
// nothing to do if this type isn't available
return null;
}

ct.ThrowIfCancellationRequested();

var extensionName = enumSymbol.Name + "Extensions";
var extensionNamespace = enumSymbol.ContainingNamespace.IsGlobalNamespace
? string.Empty
: enumSymbol.ContainingNamespace.ToString();

var attributes = enumSymbol.GetAttributes();
var enumJsonConverterAttribute = attributes.FirstOrDefault(static ad =>
ad.AttributeClass?.Name == "EnumJsonConverterAttribute" ||
ad.AttributeClass?.ToDisplayString() == EnumJsonConverterAttribute);

if (enumJsonConverterAttribute == null)
return null;

var enumExtensionsAttribute = attributes.FirstOrDefault(static ad =>
ad.AttributeClass?.Name == "EnumExtensionsAttribute" ||
ad.AttributeClass?.ToDisplayString() == EnumExtensionsAttribute);

if (enumExtensionsAttribute == null)
return null;

foreach (var namedArgument in enumExtensionsAttribute.NamedArguments)
{
switch (namedArgument.Key)
{
case "ExtensionClassNamespace" when namedArgument.Value.Value?.ToString() is { } ns:
extensionNamespace = ns;
continue;
case "ExtensionClassName" when namedArgument.Value.Value?.ToString() is { } n:
extensionName = n;
break;
}
}

ProcessNamedArguments(enumJsonConverterAttribute,
out var caseSensitive,
out var camelCase,
out var allowMatchingMetadataAttribute,
out var propertyName);

ProcessConstructorArguments(enumJsonConverterAttribute,
out var converterNamespace,
out var converterType);

if (string.IsNullOrEmpty(converterType))
return null;

var fullyQualifiedName = enumSymbol.ToString();

return new JsonConverterToGenerate
(
extensionName,
fullyQualifiedName,
extensionNamespace,
converterType!,
converterNamespace,
enumSymbol.DeclaredAccessibility == Accessibility.Public,
caseSensitive,
camelCase,
allowMatchingMetadataAttribute,
propertyName
);
}

private static void ProcessNamedArguments(AttributeData attributeData,
out bool caseSensitive,
out bool camelCase,
out bool allowMatchingMetadataAttribute,
out string? propertyName)
{
caseSensitive = false;
camelCase = false;
allowMatchingMetadataAttribute = false;
propertyName = null;

foreach (var namedArgument in attributeData.NamedArguments)
{
switch (namedArgument.Key)
{
case "CaseSensitive" when namedArgument.Value.Value?.ToString() is { } cs:
caseSensitive = bool.Parse(cs);
continue;
case "CamelCase" when namedArgument.Value.Value?.ToString() is { } cc:
camelCase = bool.Parse(cc);
continue;
case "AllowMatchingMetadataAttribute" when namedArgument.Value.Value?.ToString() is { } amma:
allowMatchingMetadataAttribute = bool.Parse(amma);
continue;
case "PropertyName" when namedArgument.Value.Value?.ToString() is { } pn:
propertyName = pn;
continue;
}
}
}

private static void ProcessConstructorArguments(AttributeData attributeData,
out string? converterNamespace,
out string? converterType)
{
if (attributeData.ConstructorArguments[0].Value is ISymbol symbol)
{
converterNamespace = !symbol.ContainingNamespace.IsGlobalNamespace
? symbol.ContainingNamespace.ToString()
: string.Empty;

converterType = symbol.Name;
}
else
{
converterNamespace = null;
converterType = null;
}
}
}
}
39 changes: 39 additions & 0 deletions src/NetEscapades.EnumGenerators/JsonConverterToGenerate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace NetEscapades.EnumGenerators
{
public readonly record struct JsonConverterToGenerate
{
public readonly string ExtensionName;
public readonly string FullyQualifiedName;
public readonly string ExtensionNamespace;
public readonly string ConverterType;
public readonly string? ConverterNamespace;
public readonly bool IsPublic;
public readonly bool CaseSensitive;
public readonly bool CamelCase;
public readonly bool AllowMatchingMetadataAttribute;
public readonly string? PropertyName;

public JsonConverterToGenerate(string extensionName,
string fullyQualifiedName,
string extensionNamespace,
string converterType,
string? converterNamespace,
bool isPublic,
bool caseSensitive,
bool camelCase,
bool allowMatchingMetadataAttribute,
string? propertyName)
{
ExtensionName = extensionName;
FullyQualifiedName = fullyQualifiedName;
ExtensionNamespace = extensionNamespace;
ConverterType = converterType;
ConverterNamespace = !string.IsNullOrEmpty(converterNamespace) ? converterNamespace : extensionNamespace;
IsPublic = isPublic;
CaseSensitive = caseSensitive;
CamelCase = camelCase;
AllowMatchingMetadataAttribute = allowMatchingMetadataAttribute;
PropertyName = propertyName;
}
}
}
Loading