Unknown Enum: Validieren von Enum-Werten mit System.Text.Json

post-thumb

Mithilfe von OpenAPI können Endpunkte und Schnittstellen bereits sehr gut dokumentiert werden. Aufrufer erhalten hierdurch nicht nur eine Einsicht, wie ein Aufruf abläuft, sondern können die Endpunkte direkt testen. Der Umgang mit Enum-Werten bringt hier insbesondere im Zusammenhang mit dem Nuget Package System.Text.Json zur De- und Serialisierung von JSON größere Herausforderungen mit sich. Immerhin lässt sich durch die Konfiguration der JsonSerializerOptions festlegen, dass nicht nur Zahlen aus dem Payload als Enum-Werte erkannt werden, sondern angegebene Texte auch entsprechend auf einen Enum-Wert gemappt werden kann (über die Angabe des JsonStringEnumConverter-Konverters in den JsonSerializerOptions).

Limitierung der Serialisierung von Enum-Werten

Und hier geraten wir auch schon an die Grenzen der Deserialisierung. Wie verhält sich die Serialisierung bei Werten, die nicht zugeordnet werden können? Ganz einfach, der Serializer von System.Text.Json wirft einen Fehler bei der Deserialisierung:

System.Text.Json.JsonException : The JSON value could not be converted to OKBlog.OKBlogCategories Path: $.oKBlogCategoriesEnum | LineNumber: 0 | BytePositionInLine: 331.

Sicherlich kann man diese Exception einfach an den Aufrufer zurück geben, allerdings ist hierdurch unklar, was letztlich der Fehler war. Auch kennt der Aufrufer dadurch nicht die zugelassen Werte für das Enum-Attribut.

Lösungsweg

Somit möchten wir als Entwickler eine Lösung bereitstellen, welche mit fehlerhaften Angaben für Enum-Werte umgehen kann und dem Aufrufer zugelassene Werte für das Enum-Attribut zurückliefert.

Hierfür muss zunächst einmal die Serialisierung soweit angepasst werden, sodass kein Fehler entsteht, sondern auf ein Defaultwert zurückgegriffen werden kann (Fallback). Zum anderen kann mithilfe eines Validators (FluentValidation) geprüft werden, ob dieser Defaultwert gesetzt worden ist und entsprechend darauf reagieren.

Defaultvalue-Mapping

Als Basis für unser Szenario definieren wir zunächst ein Enum, welche die Kategorien des OKBlogs abbildet. Was hier direkt auffällt, ist der Konstantenwert Unknown, welcher als erster Eintrag definiert ist. Da Enums per Definition Null-basierend sind, hat der erste Wert den Wert 0, der zweite die 1 usw. Dass wir hier auch selbst ganzzahlige Werte den Konstantenwerten zuweisen können, lassen wir hierbei außer Acht.

public enum OKBlogCategories
{
    /// <summary>
    /// Defaultvalue
    /// </summary>
    Unknown,

    /// <summary>
    /// How-To
    /// </summary>
    HowTo,

    /// <summary>
    /// Insights
    /// </summary>
    Insights,

    /// <summary>
    /// Quick-Tipps
    /// </summary>
    QuickTipps,

    /// <summary>
    /// Serien
    /// </summary>
    Serien
}

Nun muss ein Custom-Converter implementiert werden. System.Text.Json bietet wie Eingangs erwähnt bereits Out-of-the-Box einige Konverter, die für die De-/Serialisierung verwendet werden können. Hierzu gehört u.a. der JsonStringEnumConverter. Gleichzeitig existiert die Möglichkeit, eigene benutzerdefinierte (Custom) Konverter zu erstellen.

Hierzu erbt der Custom-Konverter von JsonConverter samt Typen, für welchen der Converter gilt. Im Konverter müssen dann folgende Methoden überschrieben und implementiert werden:

  • CanConvert

    • In dieser Methode wird geprüft, ob das eingegebene Objekt mit dem Custom-Konverter behandelt werden muss. Da wir uns hier lediglich auf Enum-Werte fokussieren möchten, wird genau hierauf geprüft (type.IsEnum).
  • Read

    • Die Read-Methode repräsentiert die Deserialisierung des Payloads. Hierbei werden drei Funktionen abgebildet:
      1. Eingabe String-Token zum Enum-Wert mappen
      2. Eingabe Zahlen-Token zum Enum-Wert mappen
      3. Fallback, falls keine der zwei vorherigen Funktionen erfolgreich waren, wird ein Defaultwert gesetzt. In diesem Fall immer der erste des Enums.
  • Write

    • In der Write-Methode wird die Serialisierung des Payloads abgebildet. In diesem Fall werden die Enum-Werte als String ausgegeben.

Die vollständige Implementierung des Konverters ist im Folgenden zu sehen:

namespace OkBlog.Samples.Json.Serialization
{
    using System;
    using System.Linq;
    using System.Text.Json;
    using System.Text.Json.Serialization;

    public class EnableDefaultValueEnumConverter : JsonConverter<object>
    {
        public override bool CanConvert(Type objectType)
        {
            Type type;

            if(objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(Nullable<>))
            {
                type = Nullable.GetUnderlyingType(objectType);
            }
            else
            {
                type = objectType;
            }

            return type.IsEnum;
        }

        public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            var isTypeNullable = typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(Nullable<>);

            var enumType = isTypeNullable ? Nullable.GetUnderlyingType(typeToConvert) : typeToConvert;
            var enumValueNames = Enum.GetNames(enumType);

            // Enum-String-Case
            if(reader.TokenType == JsonTokenType.String)
            {
                var readerEnumText = reader.GetString();

                if(!string.IsNullOrEmpty(readerEnumText))
                {
                    var matchedEnumValue = enumValueNames
                        .Where(x => string.Equals(x, readerEnumText, StringComparison.OrdinalIgnoreCase))
                        .FirstOrDefault();

                    if(matchedEnumValue != null)
                    {
                        return Enum.Parse(enumType, matchedEnumValue);
                    }
                }
            }

            // Enum-Number-Case
            if(reader.TokenType == JsonTokenType.Number)
            {
                var enumIntValue = reader.GetInt32();
                var enumValuesArr = (int[])Enum.GetValues(enumType);

                if(enumValuesArr.Contains(enumIntValue))
                {
                    return Enum.Parse(enumType, enumIntValue.ToString());
                }
            }

            // Enum-Default-Value-Case
            if(!isTypeNullable)
            {
                return Enum.Parse(enumType, enumValueNames.First());
            }

            return null;
        }

        public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
        {
            writer.WriteStringValue(value.ToString());
        }
    }
}

In den JsonSerializerOptions muss der neue Konverter hinzugefügt werden.

public static JsonSerializerOptions MyOkBlogSerializerOptions => new JsonSerializerOptions
{
    // Settings
    
    Converters =
    {
        new EnableDefaultValueEnumConverter(),
    }
};

Bei der De-/Serialisierung ist lediglich darauf zu achten, dass die MyOkBlogSerializerOptions entsprechend als Parameter übergeben wird.

var myTObject = JsonSerializer.Deserialize<T>(jsonString, MyOkBlogSerializerOptions);

Validierung mit Fluent Validation

Um dem Aufrufer eine entsprechende Antwort zurück zu liefern, verwenden wir FluentValidation. Mithilfe von FluentValidation können umfangreiche und einfach zu pflegende Validierungsregeln für die API-Entwicklung verwendet werden. Hierbei wird auf das Fluent Builder Pattern gesetzt, um auch mehrere Validierungen auf ein bestimmtes Attribut anzuwenden.

Für unseren konkreten Fall, möchten wir nun prüfen, ob der konvertierte Wert den Enum-Wert Unknown besitzt und wir entsprechend einen Fehler ausgeben, welcher die Ausprägungsmöglichkeiten des Enums zurückliefert.

namespace OkBlog.Samples.Json.Validator
{
    using OkBlog.Samples.Json.Enum;
    using FluentValidation;

    public class OKBlogValidator : AbstractValidator<OKBlog>
    {
        public OKBlogValidator()
        {
            this.RuleFor(x => x.OKBlogCategoriesEnum).NotNull().NotEqual(OKBlogCategories.Unknown)
                .WithMessage($"Type should be '{nameof(OKBlogCategories.HowTo)}' or '{nameof(OKBlogCategories.Insights)}' or '{nameof(OKBlogCategories.QuickTipps)}' or '{nameof(OKBlogCategories.Serien)}'");
        }
    }
}

Nun kann der Validator für die Prüfung der Enum-Werte verwendet und entsprechende Fehler an den Aufrufer zurückgegeben werden.

Diese Funktionalität lässt sich sehr gut in das von mir vorgestellte Best-Practice zur Entwicklung von Web APIs mit Azure Functions integrieren und nutzen.

Fazit

Die Nutzbarkeit der API wird durch den Custom-Enum-Konverter gesteigert und der Aufrufer erhält somit mehr Einsicht in das Fehlermanagement und kann den Aufruf entsprechend anpassen.

Der Objektkultur-Newsletter

Mit unserem Newsletter informieren wir Sie stets über die neuesten Blogbeiträge,
Webcasts und weiteren spannende Themen rund um die Digitalisierung.

Newsletter abonnieren