Skip to content

Instantly share code, notes, and snippets.

@otto-gebb
Last active August 24, 2018 09:05
Show Gist options
  • Save otto-gebb/87721a21582dd0e55877f3eff506b332 to your computer and use it in GitHub Desktop.
Save otto-gebb/87721a21582dd0e55877f3eff506b332 to your computer and use it in GitHub Desktop.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
// A separate NuGet package.
namespace Acme.Validation
{
public static class Validator
{
private static Action<ValidationResult> _throwStrategy =
r => throw new ValidationException(r);
public static ValidationResult Require()
{
return new ValidationResult(_throwStrategy);
}
/// <summary>
/// Call this once at the application start
/// to throw a custom exception for validation errors.
/// </summary>
/// <param name="throwStrategy">
/// A callback that takes a <see cref="ValidationResult" />
/// as a parameter and throws a custom validation excepton.
/// </param>
public static void UnsafeSetThrowStrategy(Action<ValidationResult> throwStrategy)
{
_throwStrategy = throwStrategy;
}
}
public class ValidationResult
{
public const string NoKey = "_";
private readonly Action<ValidationResult> _throwStrategy;
private Dictionary<string, List<string>> _errors = new Dictionary<string, List<string>>();
internal ValidationResult(Action<ValidationResult> throwStrategy)
{
Success = true;
_throwStrategy = throwStrategy;
}
public bool Success { get; private set; }
public ValidationResult AddError(string key, string error)
{
Success = false;
if (_errors.TryGetValue(key, out List<string> messages))
{
messages.Add(error);
}
else
{
_errors.Add(key, new List<string> { error });
}
return this;
}
public ValidationResult AddError(string error)
{
return AddError(NoKey, error);
}
public override string ToString()
{
IEnumerable<string> errors =
from kv in _errors
from v in kv.Value
select $"{kv.Key}: {v}";
return string.Join("\n", errors);
}
public IDictionary<string, string[]> ToDictionary()
{
return _errors.ToDictionary(x => x.Key, x => x.Value.ToArray());
}
public ValidationResult ThrowIfInvalid()
{
if (!Success)
{
_throwStrategy(this);
}
return this;
}
}
[Serializable]
public class ValidationException : Exception
{
public ValidationException(string message) : base(message)
{
}
public ValidationException(ValidationResult result) : base("Validation error.")
{
Errors = result.ToDictionary();
}
protected ValidationException(SerializationInfo info, StreamingContext context) : base(info, context)
{
}
public IDictionary<string, string[]> Errors { get; }
}
public static class ValidationExtensions
{
public static ValidationResult True(
this ValidationResult r,
string key,
bool condition,
string message)
{
if (!condition)
{
r.AddError(key, message);
}
return r;
}
public static ValidationResult True(
this ValidationResult r,
bool condition,
string message)
{
if (!condition)
{
r.AddError(message);
}
return r;
}
public static ValidationResult NonNullOrEmpty(
this ValidationResult r,
string key,
string value)
{
if (string.IsNullOrEmpty(value))
{
r.AddError(key, "The value must not be null or empty.");
}
return r;
}
}
}
namespace Acme.Domain
{
using Acme.Validation;
public class Entity
{
public Entity(int age, string name)
{
ValidateAgeAndNameParams(age, name).ThrowIfInvalid();
Age = age;
Name = name;
}
private static ValidationResult ValidateAgeAndNameParams(int age, string name)
{
return Validator.Require()
.NonNullOrEmpty(nameof(name), name)
.True(nameof(age), age < 0, "Value must not be negative.");
}
public void ChangeEverything(int age, string name)
{
ValidateAgeAndNameParams(age, name)
.ThrowIfInvalid()
// Check if entity state allows this operation.
.True(Name == "Constantin", "Cannot change anything with this name.")
.True(Age > 100 && Name == "Dracula", "Too late to change.")
.ThrowIfInvalid();
Age = age;
Name = name;
}
public int Age { get; private set; }
public string Name { get; private set; }
}
}
namespace Acme.App
{
using System;
using Acme.Validation;
using Acme.Domain;
using Acme.ErrorHandling;
public class Startup
{
public void ConfigureServices()
{
Validator.UnsafeSetThrowStrategy(
r => throw new CustomValidationException(r.ToDictionary()));
}
}
class AppService
{
void Change(int age, string name)
{
Validator.Require()
.True(nameof(name), !Db.Exists(name), "This name already exists.")
.ThrowIfInvalid();
Entity e = Db.LoadEntity();
e.ChangeEverything(age, name);
Db.SaveChanges();
}
}
}
// A separate package depends on ASP.Net core stuff.
namespace Acme.ErrorHandling
{
public class CustomValidationException : Exception
{
public CustomValidationException(IDictionary<string, string[]> errors)
{
Errors = errors;
}
public IDictionary<string, string[]> Errors { get; }
}
public class ErrorHandlingMiddleware
{
private readonly RequestDelegate _next;
private static readonly RouteData EmptyRouteData = new RouteData();
private static readonly ActionDescriptor EmptyActionDescriptor = new ActionDescriptor();
public ErrorHandlingMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (CustomValidationException ve)
{
await WriteError(context, new { ve.Errors });
}
}
private Task WriteError(HttpContext context, object error)
{
RouteData routeData = context.GetRouteData() ?? EmptyRouteData;
var actionContext = new ActionContext(context, routeData, EmptyActionDescriptor);
var result = new ObjectResult(error)
{
StatusCode = (int)HttpStatusCode.BadRequest
};
return result.ExecuteResultAsync(actionContext);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment