Customize JsonSchemaValidation Error Messages

Viewed 1525

I am using a Json Schema to validate a json Object. I am getting the error messages correctly. But it looks more of a developer friendly error message. Is there any way to customize the error message so that I can make it more user friendly. I have looked into many forums where i couldnt get a solution.

Below is the code i used:

        string Json = @"{'Sheet1':[{'Location':'#$','First Name':11,'Last Name':'AAAAAAAAAAAAAAAAAAAAAAAAAAAAA','Amount':'A','Date of Birth':'8522/85/25'}]}";
        string JSONSchemaValidator = @"{'$schema':'http://json-schema.org/draft-04/schema#','title':'JSON Validation Schema','type':'object','additionalProperties':true,'properties':{'Sheet1':{'type':'array','items':{'type':'object','additionalProperties':true,'properties':{'Location':{'type':['number','string','null'],'pattern':'^[a-zA-Z0-9\\-\\s]+$','maxLength':15},'First Name':{'type':['string','null'],'maxLength':20,'pattern':'^[a-zA-Z\\-\\s]+$'},'Last Name':{'type':['string','null'],'maxLength':10,'pattern':'^[a-zA-Z\\-\\s]+$'},'Amount':{'type':['number','null'],'minimum':-999999999.99,'maximum':999999999.99,'exclusiveMaximum':true,'multipleOf':0.01},'Date of Birth':{'type':['string','null'],'format':'date-time'}}}}}}";
        JSchema schema = JSchema.Parse(JSONSchemaValidator);
        JObject person = JObject.Parse(Json);
        IList<string> iJSONSchemaValidatorErrorList;
        bool valid = person.IsValid(schema, out iJSONSchemaValidatorErrorList);

        if (iJSONSchemaValidatorErrorList != null && iJSONSchemaValidatorErrorList.Count > 0)
        {
            foreach (string error in iJSONSchemaValidatorErrorList)
            {
                Console.WriteLine(error);
            }
        }
        Console.ReadKey();

Below are the error messages I get :

1. String '#$' does not match regex pattern '^[a-zA-Z0-9\-\s]+$'. Path 'Sheet1[0].Location', line 1, position 27.
2. Invalid type. Expected String, Null but got Integer. Path 'Sheet1[0]['First Name']', line 1, position 43.
3. String 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAA' exceeds maximum length of 10. Path 'Sheet1[0]['Last Name']', line 1, position 87.
4. Invalid type. Expected Number, Null but got String. Path 'Sheet1[0].Amount', line 1, position 100.
5. String '8522/85/25' does not validate against format 'date-time'. Path 'Sheet1[0]['Date of Birth']', line 1, position 129.

I am looking for something like :

 1. 'Location' in Column 1 of Sheet1 should be alphanumeric.
 2. 'Name' in Column 1 of Sheet1 should only contain alphabets.
 3. 'Last Name' in column 1 exceeds maximum length of 10.
 4. 'Amount' in column 1 should contain only numbers.
 5. 'Date of Birth' in column 1 is not a valid date.
1 Answers

I had a similar problem.

In my case, I'm using Newtonsoft.Json.Schema (yes, I paid for a license!) was generating a JSchema from System.ComponentModel.DataAnnotations attributes - and I saw it was generating a correct JSchema, but the validation error messages weren't using the ErrorMessage properties on the attributes at all.

Part 1 : Background:

It turns out that's because JSchemaGenerator generates JSchema rules using only a subset of Data-Annotation attribute properties - and it ignores the ErrorMessage property.

The set of attribute properties it does use are documented on the "Generating schemas" page:

JSchema member            Data Annotation attribute class        Attribute properties used
------------------------------------------------------------------------------------------
required                  [Required]                             
minLength                 [StringLength]                         .MinimumLength
maxLength                 [StringLength]                         .MaximumLength
minLength                 [MinLength]                            .Length
maxLength                 [MaxLength]                            .Length
minItems                  [MinLength]                            .Length
maxItems                  [MaxLength]                            .Length
pattern                   [RegularExpression]                    .Pattern
enum                      [EnumDataType]                         .EnumType
format                    [DataType]                             .DataType
format                    [Url]
format                    [Phone]
format                    [EmailAddress]
minimum                   [Range]                                .Minimum
maximum                   [Range]                                .Maximum

So armed with this knowledge, we can attempt to get the customized validation message from the attributes' ErrorMessage property by reflecting the type being deserialized and looking for matching data-annotation attributes on the appropriate PropertyInfo members.


Part 2: ValidationError

The validation errors from JSchemaValidatingReader are exposed as Newtonsoft.Json.Schema.ValidationError objects, which contains:

  • The Path property, which we'll need to reverse from the target type's property names or any matching [JsonProperty] attributes.
  • The ErrorType enum which we can use to determine which attribute types to look for.

Part 3: Static JSON Property and Data Annotation cache

For performance reasons, we don't want to reflect-over each type and attribute every time, so this code below is a static-generic cache (make sure it's marked as internal to avoid violating CA1000).

internal static class JsonPropertiesCache<T>
{
    /// <summary>Case-sensitive.</summary>
    public static IReadOnlyDictionary<String,JsonPropertyDataAnnotationAttributes> AttributesByJsonPropertyName { get; } = CreateJsonPropertiesDictionary();

    private static Dictionary<String,JsonPropertyDataAnnotationAttributes> CreateJsonPropertiesDictionary()
    {
        return typeof(T)
            .GetProperties( BindingFlags.Instance | BindingFlags.Public )
            .Select( pi => ( ok: JsonPropertyDataAnnotationAttributes.TryCreate( pi, out JsonPropertyDataAnnotationAttributes attrs ), attrs ) )
            .Where( t => t.ok )
            .Select( t => t.attrs )
#if DEBUG
            .ToDictionary( attrs => attrs.Name ); // This will throw if there's a duplicate JSON property name.
#else
            .GroupBy( attrs => attrs.Name )
            .ToDictionary( grp => grp.Key, grp => grp.First() ); // This won't throw, but will only use attributes from the first match (whichever match that is).
#endif
    }
}

public class JsonPropertyDataAnnotationAttributes
{
    public static Boolean TryCreate( PropertyInfo pi, out JsonPropertyDataAnnotationAttributes attrs )
    {
        // Only create an instance if at least one DataAnnotation validation attribute is present:

        if( pi?.GetCustomAttributes<ValidationAttribute>()?.Any() ?? false )
        {
            attrs = new JsonPropertyDataAnnotationAttributes( pi );
            return true;
        }
        else
        {
            attrs = default;
            return false;
        }
    }

    public JsonPropertyDataAnnotationAttributes( PropertyInfo pi )
    {
        this.PropertyInfo      = pi ?? throw new ArgumentNullException( nameof(pi) );
        this.JsonProperty      = pi.GetCustomAttribute<JsonPropertyAttribute>();

        this.Required          = pi.GetCustomAttribute<RequiredAttribute>();
        this.MinLength         = pi.GetCustomAttribute<MinLengthAttribute>();
        this.StringLength      = pi.GetCustomAttribute<StringLengthAttribute>();
        this.MaxLength         = pi.GetCustomAttribute<MaxLengthAttribute>();
        this.RegularExpression = pi.GetCustomAttribute<RegularExpressionAttribute>();
        this.Url               = pi.GetCustomAttribute<UrlAttribute>();
        this.Phone             = pi.GetCustomAttribute<PhoneAttribute>();
        this.Email             = pi.GetCustomAttribute<EmailAddressAttribute>();
        this.Range             = pi.GetCustomAttribute<RangeAttribute>();
        this.EnumDataType      = pi.GetCustomAttribute<EnumDataTypeAttribute>(); // NOTE: `EnumDataTypeAttribute` is a subclass of `DataTypeAttribute`.
        this.DataType          = pi.GetCustomAttribute<DataTypeAttribute>();
    }

    public PropertyInfo               PropertyInfo      { get; }
    public JsonPropertyAttribute      JsonProperty      { get; }
    public String                     Name              => this.JsonProperty?.PropertyName ?? this.PropertyInfo.Name; // TODO: Support custom NamingStrategies.

    public RequiredAttribute          Required          { get; }
    public MinLengthAttribute         MinLength         { get; }
    public StringLengthAttribute      StringLength      { get; }
    public MaxLengthAttribute         MaxLength         { get; }
    public RegularExpressionAttribute RegularExpression { get; }
    public UrlAttribute               Url               { get; }
    public PhoneAttribute             Phone             { get; }
    public EmailAddressAttribute      Email             { get; }
    public RangeAttribute             Range             { get; }
    public EnumDataTypeAttribute      EnumDataType      { get; }
    public DataTypeAttribute          DataType          { get; }

    public Boolean TryFormatValidationErrorMessage( ErrorType errorType, out String errorMessage )
    {
        switch( errorType )
        {
        case ErrorType.MaximumLength:

            errorMessage = this.MaxLength?.FormatErrorMessage( this.Name ) ?? this.StringLength?.FormatErrorMessage( this.Name );
            return !String.IsNullOrEmpty( errorMessage );

        case ErrorType.MinimumLength:

            errorMessage = this.MinLength?.FormatErrorMessage( this.Name ) ?? this.StringLength?.FormatErrorMessage( this.Name );
            return !String.IsNullOrEmpty( errorMessage );

        case ErrorType.Pattern:

            errorMessage = this.RegularExpression?.FormatErrorMessage( this.Name );
            return !String.IsNullOrEmpty( errorMessage );
                
        case ErrorType.MaximumItems:
                
            errorMessage = this.MaxLength?.FormatErrorMessage( this.Name );
            return !String.IsNullOrEmpty( errorMessage );

        case ErrorType.MinimumItems:
                
            errorMessage = this.MinLength?.FormatErrorMessage( this.Name );
            return !String.IsNullOrEmpty( errorMessage );

        case ErrorType.Required:
                
            errorMessage = this.Required?.FormatErrorMessage( this.Name );
            return !String.IsNullOrEmpty( errorMessage );

        case ErrorType.Enum:
                
            errorMessage = this.EnumDataType?.FormatErrorMessage( this.Name );
            return !String.IsNullOrEmpty( errorMessage );

        case ErrorType.Format:

            // Same precedence order as used by Newtonsoft.Json.Schema.Infrastructure::GetFormat(JsonProperty):
                errorMessage = this.Url?.FormatErrorMessage( this.Name ) ?? this.Phone?.FormatErrorMessage( this.Name ) ?? this.Email?.FormatErrorMessage( this.Name ) ?? this.DataType?.FormatErrorMessage( this.Name );
                return !String.IsNullOrEmpty( errorMessage );

        case ErrorType.Maximum:
        case ErrorType.Minimum:
                
            errorMessage = this.Range?.FormatErrorMessage( this.Name );
            return !String.IsNullOrEmpty( errorMessage );

        default:
            errorMessage = default;
            return false;
        }
    }
}

Part 4: Getting ErrorMessage from SchemaValidationEventArgs

So given your set of SchemaValidationEventArgs, getting the human-readable validation errors is a simple case of matching the type you're deserializing and using the dictionary above.

Caveat: This code does not currently support DTO class JSON properties that are classes themselves - but implementing that is rather straightforward and is an exercise for the reader.

private static Result<T> DeserializeAndValidate<T>( String body, JSchema schema )
{
    if( body == null ) throw new ArgumentNullException(nameof(body));

    //

    List<SchemaValidationEventArgs> messages = new List<SchemaValidationEventArgs>();

    using( StringReader textReader = new StringReader( body ) )
    using( JsonTextReader jsonTextReader = new JsonTextReader( textReader ) )
    using( JSchemaValidatingReader validatingReader = new JSchemaValidatingReader( jsonTextReader ) )
    {
        validatingReader.Schema = schema;
        validatingReader.ValidationEventHandler += ( sender, eventArgs ) => messages.Add( eventArgs );

        //

        JsonSerializer serializer = new JsonSerializer();

        T value = serializer.Deserialize<T>( validatingReader );
                
        if( messages.Count == 0 )
        {
            return value;
        }
        else
        {
            IReadOnlyDictionary<String,JsonPropertyDataAnnotationAttributes> dict = JsonPropertiesCache<T>.AttributesByJsonPropertyName;

            return messages
                .Select( e => e.ValidationError )
                .Flatten( err => err.ChildErrors )
                .Select( err => ( err, ok: dict.TryGetValue( err.Path, out JsonPropertyDataAnnotationAttributes attr ), attr ) )
                .Select( t => t.ok && t.attr.TryFormatValidationErrorMessage( t.err.ErrorType, out String errorMessage ) ? errorMessage : t.err.Message )
                .StringJoin( separator: "\r\n" );
        }
Related