- Documentation
- This documentation is written in short points.
- Sometimes a point contains a subpoint.
- Occasionally, a point could have a source code following it.
- It's for demonstration, and the code is also commented in italic font.
- Most code examples in this documentation are using the following set of models:
public class BookModel
{
public string Title { get; set; }
public IEnumerable<AuthorModel> Authors { get; set; }
public IEnumerable<Language> Languages { get; set; }
public int YearOfFirstAnnouncement { get; set; }
public int? YearOfPublication { get; set; }
public PublisherModel Publisher { get; set; }
public bool IsSelfPublished { get; set; }
}
public class AuthorModel
{
public string Name { get; set; }
public string Email { get; set; }
}
public class PublisherModel
{
public string CompanyId { get; set; }
public string Name { get; set; }
}
public enum Language
{
English,
Polish
}
Comments are usually placed below the code sample, but that's not the rock-solid principle. The important thing is that they are related to the preceding point, while the next point starts the new thing.
- Vast majority of the code snippets live as functional tests in the separate project.
- Specification is an expression that uses fluent api to describe all conditions of a valid object.
- Technically, specification is a generic delegate, and in most cases, you'll see it in the form of a lambda function.
- If you prefer the approach of wrapping validation logic into a separate class, use the specification holder.
- Specification - considered purely as a C# function - is executed by the validator during its construction (directly or through the factory).
- Fluent api consist of commands called in so-called method chain:
Specification<int> yearSpecification = m => m
.GreaterThan(-10000)
.NotEqualTo(0).WithMessage("There is no such year as 0")
.LessThan(3000);
Above; four chained commands: GreaterThan
, NotEqualTo
, WithMessage
, LessThan
. All of them - the entire specification - is the single scope that validates value of type int
.
- Logically, specification consist of scopes. And the scope could be explained as:
- Set of commands that describe validation rules for the same value.
- This value is often referred to in this documentation as "scope value".
- If the value is null, scope acts according to the null policy.
- Set of commands that describe validation rules for the same value.
Specification<int> yearSpecification = s => s
.GreaterThan(-10000)
.NotEqualTo(0).WithMessage("There is no such year as 0")
.LessThan(3000);
Specification<BookModel> bookSpecification = s => s
.Member(m => m.YearOfFirstAnnouncement, yearSpecification)
.Member(m => m.YearOfPublication, m => m
.Positive()
)
.Rule(m => m.YearOfPublication == m.YearOfFirstAnnouncement).WithMessage("Same year in both places is invalid");
Above; yearSpecification
contains four commands in its scope, all validating the value of type int
.
Next one, bookSpecification
, is more complex. Let's analyse it:
First Member command steps into the BookModel
's member of type int
named YearOfFirstAnnouncement
and in its scope validates the value using the yearSpecification
defined earlier.
Second Member command opens scope that validates YearOfPublication
; this scope contains single rule, Positive
. Also, according to the null policy, it requires the nullable member YearOfPublication
to have a value.
The last scope command, Rule contains a piece of logic for BookModel
and parameter command WithMessage defines the error message if the predicate fails.
- You can also say that specification is a scope. A "root level" scope.
- All commands and their logic are related to a single value (of type
T
inSpecification<T>
). - The null policy is followed here as well.
- Commands that validate parts of the model are using... specification to describe the scope rules.
- Even the root scope behaves as it was placed in AsModel command.
- All commands and their logic are related to a single value (of type
- There are three types of commands:
- Scope commands - contain validation logic and produce error output.
- Parameter commands - changes the behavior of the preceding scope command.
- Presence commands - sets the scope behavior in case of null value.
- Scope command is a command that validates the model by:
- executing the validation logic directly:
- Rule - executes a custom predicate.
- RuleTemplate and all of the built-in rules - executes a predefined piece of logic.
- executing the validation logic wrapped in another specification, in the way dependent on the scope value type:
- Member - executes specification on the model's member.
- AsModel - executes specification on the model.
- AsCollection - executes specification on each item of the collection type model.
- AsNullable - executes specification on the value of the nullable type model.
- executing the validation logic directly:
Specification<AuthorModel> authorSpecification = m => m
.Member(m => m.Name, m => m.NotWhiteSpace().MaxLength(100))
.Member(m => m.Email, m => m.Email())
.Rule(m => m.Email != m.Name);
In the above code you can see the specification containing only scope commands.
- Scope command produces error output if - by any bit of a validation logic - the scope value is considered as invalid.
- How is "scope" term related with scope command?
- Good to read; Specification - also tries to describe what is a scope.
- All scope commands (except for Rule and RuleTemplate) validate the value by executing a specification (which is a scope).
- Rule and RuleTemplate are slightly different. They contain the most atomic part of validation logic - a predicate. They are still scope commands, because:
- They determine if the value is valid or not. The only difference is that they execute the logic directly instead of wrapped within another scope.
- They produce error output in case of validation error.
- Parameter command is a command that affects (parametrizes) the closest scope command placed before it.
- WithCondition - sets execution condition.
- WithPath - sets the path for the error output.
- WithMessage - overwrites the entire error output with a single message.
- WithExtraMessage - appends a single message to the error output.
- WithCode - overwrites the entire error output with a single code.
- WithExtraCode - appends a single code to the error output.
- Parameter commands have their order strictly defined and enforced by the language constructs.
- So you might notice that some commands are not available from certain places.
- Example: AsNullable can't be called in the scope that validates
int
. - Example: WithCode can't be called after WithMessage, because that doesn't make much sense (double overwrite...).
- Example: AsNullable can't be called in the scope that validates
- To know what other commands are allowed to be placed before/after, read the section about the particular command.
- So you might notice that some commands are not available from certain places.
- It doesn't matter how many parameter commands are defined in the row - they are all related to the closest preceding scope command (or presence command).
- All the parameter commands start with
With...
, so it's easy to group them visually:
- All the parameter commands start with
Specification<AuthorModel> authorSpecification = s => s
.Member(m => m.Name, m => m.NotWhiteSpace().MaxLength(100))
.WithCondition(m => !string.IsNullOrEmpty(m.Name))
.WithPath("AuthorName")
.WithCode("AUTHOR_NAME_ERROR")
.Member(m => m.Email, m => m.Email())
.WithMessage("Invalid email!")
.WithExtraCode("EMAIL_ERROR")
.Rule(m => m.Email != m.Name)
.WithCondition(m => m.Email != null && m.Name != null)
.WithPath("Email")
.WithMessage("Name can't be same as Email");
Above, you can see that the first Member command is configured with the following parameters commands: WithCondition, WithPath and WithCode.
The second Member command is configured with WithMessage, and WithExtraCode commands.
The third scope command - Rule - is configured with WithCondition, WithPath, and WithMessage commands
- Presence command is the command that defines the behavior of the entire scope in case of null scope value:
- Only one presence command is allowed within the scope.
- Presence command needs to be the first command in the scope.
- Presence commands produce error output that can be modified with some of the parameter commands.
- Not all of them, because e.g. you can't change their path or set an execution condition.
- Good to read: Handling nulls - details about the null value validation strategy.
Specification<AuthorModel> authorSpecification = m => m
.Optional()
.Member(m => m.Name, m => m
.Optional()
.NotWhiteSpace()
.MaxLength(100)
)
.Member(m => m.Email, m => m
.Required().WithMessage("Email is obligatory.")
.Email()
)
.Rule(m => m.Email != m.Name);
In the example above the entire model is allowed to be null. Similarly - Name
member. Email
is required, but the error output will contain a custom message (Email is obligatory.
) in case of null.
- Error output is everything that is returned from the scope if - according to the internal logic - the scope value is invalid.
- Therefore, the absence of error output means that the value is valid.
- Error output can contain:
- Error messages - human-readable messages explaining what went wrong.
- Error codes - flags that help to organize the logic around specific errors.
- Both. There are no limitations around that. The error output can contain only messages, only codes, or a mix.
- The validation process assigns every error output to the path where it was produced.
- The path shows the location where the error occurred.
- Sometimes this documentation refers to this action as "saving error output under the path"
- Good to read:
- Messages are primarily targeted to humans.
- Use case; logs and the details about invalid models incoming from the frontend.
- Use case; rest api returning messages that frontend shows in the pop up.
- Error output can contain one or more error messages.
- Good to read:
- Translations - how to translate a message or overwrite the default one.
- Message arguments - how to use message arguments.
- MessageMap - how to read messages from the validation result.
- Message can be set using WithMessage, WithExtraMessage, and RuleTemplate commands.
Specification<int> yearSpecification = s => s
.Rule(year => year > -300)
.WithMessage("Minimum year is 300 B.C.")
.WithExtraMessage("Ancient history date is invalid.")
.Rule(year => year != 0)
.WithMessage("The year 0 is invalid.")
.WithExtraMessage("There is no such year as 0.")
.Rule(year => year < 10000)
.WithMessage("Maximum year is 10000 A.D.");
var validator = Validator.Factory.Create(yearSpecification);
var result = validator.Validate(-500);
result.MessageMap[""][0] // Minimum year is 300 B.C.
result.MessageMap[""][1] // Ancient history date is invalid.
validator.ToString();
// Minimum year is 300 B.C.
// Ancient history date is invalid.
In the above code, MessageMap holds the messages assigned to their paths. Empty string as a path means that the error is recorded for the root model.
- Printing returned by ToString method includes the path before each message.
Specification<int> yearSpecification = s => s
.Rule(year => year > -300)
.WithMessage("Minimum year is 300 B.C.")
.WithExtraMessage("Ancient history date is invalid.")
.Rule(year => year != 0)
.WithMessage("The year 0 is invalid.")
.WithExtraMessage("There is no such year as 0.")
.Rule(year => year < 10000)
.WithMessage("Maximum year is 10000 A.D.");
Specification<BookModel> bookSpecification = s => s
.Member(m => m.YearOfFirstAnnouncement, yearSpecification)
.Member(m => m.YearOfPublication, m => m.AsNullable(yearSpecification))
.Rule(m => m.YearOfFirstAnnouncement <= m.YearOfPublication)
.WithCondition(m => m.YearOfPublication.HasValue)
.WithMessage("Year of publication must be after the year of first announcement");
var validator = Validator.Factory.Create(bookSpecification);
var book = new BookModel() { YearOfFirstAnnouncement = 0, YearOfPublication = -100 };
var result = validator.Validate(book);
result.MessageMap[""][0]; // Year of publication must be after the year of first announcement
result.MessageMap["YearOfFirstAnnouncement"][0]; // "The year 0 is invalid.
result.MessageMap["YearOfFirstAnnouncement"][1]; // There is no such year as 0.
result.ToString();
// Year of publication must be after the year of first announcement
// YearOfFirstAnnouncement: The year 0 is invalid.
// YearOfFirstAnnouncement: There is no such year as 0.
- Codes are primarily for the parsers and interpreters - they should be short flags, easy to process.
- Code cannot contain white space characters.
- Good to read:
Specification<int> yearSpecification = s => s
.Rule(year => year > -300)
.WithCode("MAX_YEAR")
.Rule(year => year != 0)
.WithCode("ZERO_YEAR")
.WithExtraCode("INVALID_VALUE")
.Rule(year => year < 10000)
.WithCode("MIN_YEAR");
var validator = Validator.Factory.Create(yearSpecification);
var result = validator.Validate(0);
result.Codes; // [ "ZERO_YEAR", "INVALID_VALUE" ]
result.CodeMap[""][0]; // [ "ZERO_YEAR" ]
result.CodeMap[""][1]; // [ "INVALID_VALUE" ]
result.ToString();
// ZERO_YEAR, INVALID_VALUE
In the above example, CodeMap acts similarly to MessageMap. Also, for your convenience, Codes holds all the error codes in one place. ToString() called on the result prints error codes, coma separated, in the first line.
- Path is a string that shows the way of reaching the value that is invalid.
- "The way" means which members need to be traversed through in order to reach the particular value.
- Example;
Author.Email
path describes the value ofEmail
that is insideAuthor
.
- Path contains segments, and each one stands for one member that the validation context needs to enter in order to reach the value.
- Path segments are separated with
.
(dot character). - Member, which is the way of stepping into the nested level uses the member's name as a segment.
- Path segments are separated with
model.Member.NestedMember.MoreNestedMember.Email = "invalid_email_value";
var result = validator.Validate(model);
result.MessageMap["Member.NestedMember.MoreNestedMember.Email"][0]; // Must be a valid email address
result.ToString();
// Member.NestedMember.MoreNestedMember.Email: Must be a valid email address
- When it comes to collections (validated with AsCollection, n-th (counting from zero) item is considered as the member named
#n
.
model.MemberCollection[0].NestedMember.MoreNestedMemberCollection[23].Email = "invalid_email_value";
var result = validator.Validate(model);
result.MessageMap["MemberCollection[0].NestedMember.MoreNestedMemberCollection[23].Email"][0]; // Must be a valid email address
result.ToString();
// MemberCollection[0].NestedMember.MoreNestedMemberCollection[23]: Must be a valid email address
Above, MemberCollection.#0.NestedMember.MoreNestedMemberCollection.#23.Email:
is the path that leads through 1st item of MemberCollection
and 24th item of MoreNestedMemberCollection
.
- You are free to modify the path of every error output using WithPath.
- The order the commands in the specification is strictly enforced by the language constructs. Invalid order means compilation error.
Rule
is a scope command.- Can be placed after:
- any command except Forbidden.
- Can be followed by:
- any of the scope commands.
- any of the parameter commands.
- Can be placed after:
Rule
defines a single, atomic bit of validation logic with a predicate that accepts the scope value and returns:true
, if the scope value is valid.false
, if the scope value in invalid.
Predicate<int> isAgeValid = age => (age >= 0) && (age < 18);
Specification<int> ageSpecification = m => m.Rule(isAgeValid);
var ageValidator = Validator.Factory.Create(ageSpecification);
ageValidator.IsValid(12); // true
ageValidator.IsValid(20); // false
ageValidator.Validate(32).ToString();
// Error
- If the predicate returns
false
, theRule
scope returns error output.- The default error output of
Rule
command is a single message keyGlobal.Error
- Default English translation for it is just
Error
.
- Default English translation for it is just
- It can be altered with WithMessage command.
- The default error output of
Predicate<int> isAgeValid = age => (age >= 0) && (age < 18);
Specification<int> ageSpecification = m => m.Rule(isAgeValid).WithMessage("The age is invalid");
var ageValidator = Validator.Factory.Create(ageSpecification);
ageValidator.Validate(32).ToString();
// The age is invalid
This is just a regular usage of WithMessage command that overwrites the entire error output of the preceding scope command (in this case - Rule
).
Rule
can be used to validate dependencies between the scope object's members.- If the error output of such validation should be placed in the member scope rather than its parent, use WithPath command.
Specification<BookModel> bookSpecification = m => m
.Rule(book => book.IsSelfPublished == (book.Publisher is null)).WithMessage("Book must have a publisher or be self-published.");
var bookValidator = Validator.Factory.Create(bookSpecification);
bookValidator.Validate(new BookModel() { IsSelfPublished = true, Publisher = new PublisherModel() }).ToString();
// Book must have a publisher or be self-published.
bookValidator.Validate(new BookModel() { IsSelfPublished = true, Publisher = null }).AnyErrors; // false
- The value received in the predicate as an argument is never null.
- All null-checks on it are redundant, no matter what code analysis has to say about it.
- Although the received value is never null, its members could be!
Specification<PublisherModel> publisherSpecification = m => m
.Rule(publisher =>
{
if (publisher.Title.Contains(publisher.CompanyId))
{
return false;
}
return true;
});
var validator = Validator.Factory.Create(publisherSpecification);
validator.Validate(new PublisherModel()); // throws NullReferenceException
In the above example, publisher
argument is never null, but Title
and CompanyId
could be, thus it's high a risk of NullReferenceException
.
- All unhandled exceptions are bubbled up to the surface and can be caught from
Validate
method.- Exceptions are unmodified and are not wrapped.
var verySpecialException = new VerySpecialException();
Specification<BookModel> bookSpecification = m => m.Rule(book => throw verySpecialException);
var bookValidator = Validator.Factory.Create(bookSpecification);
try
{
bookValidator.Validate(new BookModel());
}
catch(VerySpecialException exception)
{
object.ReferenceEquals(exception, verySpecialException); // true
}
- After processing the Specification, the validator stores the predicate in its internals.
- This is the very reason to be double-cautious when "capturing" variables in the predicate function as you're risking memory leak. Especially when the validator is registered as a singleton in a DI container.
RuleTemplate
is a scope command.- Can be placed after:
- any command except Forbidden.
- Can be followed by:
- any of the scope commands.
- any of the parameter commands.
- Can be placed after:
RuleTemplate
is a special version of Rule.- All of the details described in the Rule section also apply to
RuleTemplate
.
- All of the details described in the Rule section also apply to
- The purpose of
RuleTemplate
is to deliver a convenient foundation for predefined, reusable rules.- All built-in rules use
RuleTemplate
under the hood. There are no exceptions, hacks, or special cases. - So if you decide to write your own custom rules, you're using the exact same api that the Validot uses.
- All built-in rules use
- Technically, there is nothing wrong in placing
RuleTemplate
in the specification directly, but it's not considered as a good practice.- You should rather limit the usage of
RuleTemplate
to its purpose; custom rules.
- You should rather limit the usage of
RuleTemplate
accepts three parameters:message
sets the single error message that will be in the error output if the predicate returnsfalse
.- So the result is the same as when using
Rule
followed byWithMessage
. Below example presents that:
- So the result is the same as when using
Predicate<int> isAgeValid = age => (age >= 0) && (age < 18);
Specification<int> ageSpecification1 = m => m.Rule(isAgeValid).WithMessage("The age is invalid");
Specification<int> ageSpecification2 = m => m.RuleTemplate(isAgeValid, "The age is invalid");
var ageValidator1 = Validator.Factory.Create(ageSpecification1);
var ageValidator2 = Validator.Factory.Create(ageSpecification2);
ageValidator1.Validate(32).ToString();
// The age is invalid
ageValidator2.Validate(32).ToString();
// The age is invalid
The above code presents that there is no difference between the basic usage of Rule and RuleTemplate.
args
parameter is optional, and it's a collection of arguments that can be used in placeholders within the error message.- Each argument needs to be created with
Arg
static factory- Ok, technically it doesn't need to be created by the factory, but it's highly recommended as implementing
IArg
yourself could be difficult and more support for it is planned, but not in the very nearly future.
- Ok, technically it doesn't need to be created by the factory, but it's highly recommended as implementing
- Factory contains helper methods to create arguments related with enums, types, texts, numbers, and guids.
- When creating an argument, factory needs:
name
- needs to be unique across the collection of arguments.- it's the base part of the placeholder:
{name}
- it's the base part of the placeholder:
- value - value that the message can use
Arg.Number("minimum", 123)
- creates a number argument namedminimum
withint
value of123
Arg.Text("title", "Star Wars")
- creates text argument namedtitle
withstring
value of"Star Wars"
- Good to read: Message arguments - how to use arguments in messages
- Each argument needs to be created with
- Placeholders in the error message will be replaced with the value of the related argument.
- Name must be the same
- Placeholder needs follow the pattern:
{argumentName}
Predicate<int> isAgeValid = age => (age >= 0) && (age < 18);
Specification<int> ageSpecification = m => m
.RuleTemplate(isAgeValid, "Age must be between {minAge} and {maxAge}", Arg.Number("minAge", 0), Arg.Number("maxAge", 18));
var ageValidator = Validator.Factory.Create(ageSpecification);
ageValidator.Validate(32).ToString();
// Age must be between 0 and 18
- Optionally, placeholders can contain additional parameters:
- Good to read: Message arguments
Predicate<int> isAgeValid = age => (age >= 0) && (age < 18);
Specification<int> ageSpecification = m => m
.RuleTemplate(
isAgeValid,
"Age must be between {minAge|format=0.00} and {maxAge|format=0.00|culture=pl-PL}",
Arg.Number("minAge", 0),
Arg.Number("maxAge", 18)
);
var ageValidator = Validator.Factory.Create(ageSpecification);
ageValidator.Validate(32).ToString();
// Age must be between 0.00 and 18,00
Notice that the format follows dotnet custom numeric format strings. The maxAge
argument also has a different culture set (pl-PL
, so ,
as a divider instead of .
).
- Not all arguments need to be used.
- One argument can be used more than once in the same message.
- If there is any error (like invalid name of the argument or parameter), no exception is thrown in the code, but the string, unformatted, goes directly to the error output.
Predicate<int> isAgeValid = age => (age >= 0) && (age < 18);
Specification<int> ageSpecification = m => m
.RuleTemplate(
isAgeValid,
"Age must be between {minAge|format=0.00} and {maximumAge|format=0.00|culture=pl-PL}",
Arg.Number("minAge", 0),
Arg.Number("maxAge", 18)
);
var ageValidator = Validator.Factory.Create(ageSpecification);
ageValidator.Validate(32).ToString();
// "Age must be between 0.00 and {maximumAge|format=0.00|culture=pl-PL}"
In the above example, maximumAge
is invalid argument name (maxAge
would be OK in this case) and therefore - the placeholder stays as it is.
RuleTemplate
exposes its arguments to all messages in its error output.- Each message can contain only a subset of arguments.
- Each message is free to use any formatting it wants.
Predicate<int> isAgeValid = age => (age >= 0) && (age < 18);
Specification<int> ageSpecification = m => m
.RuleTemplate(
isAgeValid,
"Age must be between {minAge|format=0.00} and {maxAge|format=0.00|culture=pl-PL}",
Arg.Number("minAge", 0),
Arg.Number("maxAge", 18)
)
.WithExtraMessage("Must be more than {minAge}")
.WithExtraMessage("Must be below {maxAge|format=0.00}! {maxAge}!");
var ageValidator = Validator.Factory.Create(ageSpecification);
ageValidator.Validate(32).ToString();
// Age must be between 0.00 and 18,00
// Must be more than 0
// Must be below 18.00! 18!
- Arguments passed to
RuleTemplate
are also available in WithMessage and WithExtraMessage.
Predicate<int> isAgeValid = age => (age >= 0) && (age < 18);
Specification<int> ageSpecification = m => m
.RuleTemplate(
isAgeValid,
"Age must be between {minAge|format=0.00} and {maxAge|format=0.00|culture=pl-PL}",
Arg.Number("minAge", 0),
Arg.Number("maxAge", 18)
)
.WithMessage("Only {minAge}-{maxAge}!");
var ageValidator = Validator.Factory.Create(ageSpecification);
ageValidator.Validate(32).ToString();
// Only 0-18!
- Because all the built-in rules are based on
RuleTemplate
, this is the magic behind altering their error message and still having access to the arguments.
Specification<int> ageSpecification = m => m.Between(min: 0, max: 18).WithMessage("Only {min}-{max|format=0.00}!");
var ageValidator = Validator.Factory.Create(ageSpecification);
ageValidator.Validate(32).ToString();
// Only 0-18!
In the above example, Between
is a built-in rule for int
type values that exposes min
and max
parameters to be used in the error messages.
- Good to read:
- Message arguments - everything about the available arguments, their types, and parameters.
- Custom rules - how to create a custom rule, step by step.
- Rules - the detailed list of all arguments available in each of the built-in rule.
Member
is a scope command.- Can be placed after:
- any command except Forbidden.
- Can be followed by:
- any of the scope commands.
- any of the parameter commands.
- Can be placed after:
Member
executes a specification upon a scope object's member.Member
command accepts:- member selector - a lambda expression pointing at a scope object's member.
- specification - specification to be executed upon the selected member.
- Member selector serves two purposes:
- It points at the member that will be validated with the passed specification.
- So technically it determines type
T
inSpecification<T>
thatMember
accepts as a second parameter.
- So technically it determines type
- It defines the nested path under which the entire error output from the passed specification will be saved.
- By default, if the member selector is
m => m.Author
, the error output will be saved under the pathAuthor
(as a next segment).
- By default, if the member selector is
- It points at the member that will be validated with the passed specification.
Specification<string> nameSpecification = s => s
.Rule(name => name.All(char.IsLetter)).WithMessage("Must consist of letters only!")
.Rule(name => !name.Any(char.IsWhiteSpace)).WithMessage("Must not contain whitespace!");
var nameValidator = Validator.Factory.Create(nameSpecification);
nameValidator.Validate("Adam !!!").ToString();
// Must consist of letters only!
// Must not contain whitespace!
In the above example, you can see specification and validation of a string value. Let's use this exact specification inside Member
command and observe how the entire output is saved under a nested path:
Specification<PublisherModel> publisherSpecification = s => s
.Member(m => m.Name, nameSpecification);
var publisherValidator = Validator.Factory.Create(publisherSpecification);
var publisher = new PublisherModel()
{
Name = "Adam !!!"
};
publisherValidator.Validate(publisher).ToString();
// Name: Must consist of letters only!
// Name: Must not contain whitespace!
Let's add one more level:
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Publisher, publisherSpecification);
var bookValidator = Validator.Factory.Create(bookSpecification);
var book = new BookModel()
{
Publisher = new PublisherModel()
{
Name = "Adam !!!"
}
};
authorValidator.Validate(book).ToString();
// Publisher.Name: Must consist of letters only!
// Publisher.Name: Must not contain whitespace!
- Whether to define a specification upfront and pass it to the
Member
command or define everything inline - it's totally up to you. It doesn't make any difference.- The only thing that is affected is the source code readability.
- However, in some particular situations, reusing predefined specifications could lead to having an infinite reference loop in the object. This topic is covered in Reference loop section.
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Publisher, m => m
.Member(m1 => m1.Name, m1 => m1
.Rule(name => name.All(char.IsLetter)).WithMessage("Must consist of letters only!")
.Rule(name => !name.Any(char.IsWhiteSpace)).WithMessage("Must not contain whitespace!")
)
);
var bookValidator = Validator.Factory.Create(bookSpecification);
var book = new BookModel()
{
Publisher = new PublisherModel()
{
Name = "Adam !!!"
};
};
authorValidator.Validate(book).ToString();
// Publisher.Name: Must consist of letters only!
// Publisher.Name: Must not contain whitespace!
- Selected member can be only one level from the scope object!
- No language construct prevents you from stepping into more nested levels (so no compilation errors), but then, during runtime, validator throws the exception from its constructor (or factory).
- This behavior is very likely to be updated in the future versions, so such selectors might be allowed someday... but not now.
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Publisher.Name, nameSpecification);
Validator.Factory.Create(bookSpecification); // throws exception
In the above example, the exception is thrown because member selector goes two levels down (Publisher.Name
). Please remember that one level down is allowed (just Publisher
would be totally OK).
- Selected member can be either property or variable.
- It can't be a function.
- Type of selected member doesn't matter (can be a reference type, value type, string, enum, or whatever...).
- The default path for the error output (determined by the member selector) can be altered using WithPath command.
- If the selected member contains null, the member scope is still executed and the error output entirely depends on the specification.
- It means that null member is not anything special. It's a normal situation, and the behavior relies on the passed specification, its presence commands, and the null handling strategy.
Specification<PublisherModel> publisherSpecification = s => s
.Member(m => m.Name, m => m
.Rule(name => name.All(char.IsLetter)).WithMessage("Must consist of letters only!")
.Rule(name => !name.Any(char.IsWhiteSpace)).WithMessage("Must not contain whitespace!")
);
Specification<PublisherModel> publisherSpecificationRequired = s => s
.Member(m => m.Name, m => m
.Required().WithMessage("Must be filled in!")
.Rule(name => name.All(char.IsLetter)).WithMessage("Must consist of letters only!")
.Rule(name => !name.Any(char.IsWhiteSpace)).WithMessage("Must not contain whitespace!")
);
Specification<PublisherModel> publisherSpecificationOptional = s => s
.Member(m => m.Name, m => m
.Optional()
.Rule(name => name.All(char.IsLetter)).WithMessage("Must consist of letters only!")
.Rule(name => !name.Any(char.IsWhiteSpace)).WithMessage("Must not contain whitespace!")
);
var publisherValidator = Validator.Factory.Create(publisherSpecification);
var publisherValidatorRequired = Validator.Factory.Create(publisherSpecificationRequired);
var publisherValidatorOptional = Validator.Factory.Create(publisherSpecificationOptional);
var publisher = new PublisherModel()
{
Name = null
};
publisherValidator.Validate(publisher).ToString();
// Name: Required
publisherValidatorRequired.Validate(publisher).ToString();
// Name: Must be filled in!
publisherValidatorOptional.Validate(publisher).AnyErrors; // false
Without any presence command in publisherSpecification
, the default behavior is to require the scope value to be non-null. The error message can be customized (publisherSpecificationRequired
) with Required command followed by WithMessage.
If the specification starts with Optional
, no error is returned from the member scope.
AsModel
is a scope command.- Can be placed after:
- any command except Forbidden.
- Can be followed by:
- any of the scope commands.
- any of the parameter commands.
- Can be placed after:
AsModel
executes a specification upon the scope value.AsModel
command accepts only one argument; a specificationSpecification<T>
, whereT
is the current scope type.- Technically
AsModel
executes specification in the same scope that it lives itself.- So you can say it's like Member command, but it doesn't step into any member.
Specification<string> emailSpecification = s => s
.Rule(email => email.Contains('@')).WithMessage("Must contain @ character!");
Specification<string> emailAsModelSpecification = s => s
.AsModel(emailSpecification);
var emailValidator = Validator.Factory.Create(emailSpecification);
var emailAsModelValidator = Validator.Factory.Create(emailAsModelSpecification);
emailValidator.Validate("invalid email").ToString();
// Must contain @ character!
emailAsModelValidator.Validate("invalid email").ToString();
// Must contain @ character!
In the above code you can see that it doesn't matter whether specification is used directly or through AsModel
- the validation logic is the same and the error output is saved under the same path.
Specification<string> emailSpecification = s => s
.Rule(email => email.Contains('@')).WithMessage("Must contain @ character!");
Specification<string> emailNestedAsModelSpecification = s => s
.AsModel(s1 => s1
.AsModel(s2 => s2
.AsModel(emailSpecification)
)
);
var emailValidator = Validator.Factory.Create(emailSpecification);
var emailNestedAsModelValidator = Validator.Factory.Create(emailNestedAsModelSpecification);
emailValidator.Validate("invalid email").ToString();
// Must contain @ character!
emailAsModelValidator.Validate("invalid email").ToString();
// Must contain @ character!
The above example presents that even several levels of nested AsModel
commands don't make any difference.
AsModel
can be used to execute many independent specifications on the same value.- Effectively, it's like merging specifications into one.
Specification<string> atRequiredSpecification = s => s
.Rule(text => text.Contains('@')).WithMessage("Must contain @ character!");
Specification<string> allLettersLowerCaseSpecification = s => s
.Rule(text => !text.Any(c => !char.IsLetter(c) || char.IsLower(c))).WithMessage("All letters need to be lower case!");
Specification<string> lengthSpecification = s => s
.Rule(text => text.Length > 5).WithMessage("Must be longer than 5 characters")
.Rule(text => text.Length < 20).WithMessage("Must be shorter than 20 characters");
Specification<string> emailSpecification = s => s
.AsModel(atRequiredSpecification)
.AsModel(allLettersLowerCaseSpecification)
.AsModel(lengthSpecification);
var emailValidator = Validator.Factory.Create(emailSpecification);
emailValidator.Validate("Email").ToString();
// Must contain @ character!
// All letters need to be lower case!
// Must be longer than 5 characters
In the above example, you can see how three separate specifications are - practically - combined into one.
AsModel
can be used to mix predefined specifications with inline rules.- Thanks to this, you might "modify" the presence rule in the predefined specification.
Specification<string> atRequiredSpecification = s => s
.Rule(text => text.Contains('@')).WithMessage("Must contain @ character!");
Specification<string> allLettersLowerCaseSpecification = s => s
.Rule(text => !text.Any(c => !char.IsLetter(c) || char.IsLower(c))).WithMessage("All letters need to be lower case!");
Specification<string> emailSpecification = s => s
.Optional()
.AsModel(atRequiredSpecification)
.AsModel(allLettersLowerCaseSpecification)
.Rule(text => text.Length > 5).WithMessage("Must be longer than 5 characters")
.Rule(text => text.Length < 20).WithMessage("Must be shorter than 20 characters");
var emailValidator = Validator.Factory.Create(emailSpecification);
emailValidator.Validate("Email").ToString();
// Must contain @ character!
// All letters need to be lower case!
// Must be longer than 5 characters
emailValidator.Validate(null).AnyErrors; // false
The example above shows that predefined specification can be expanded with more rules (AsModel
and subsequent Rule commands).
Also, you can observe the interesting behavior that can be described as presence rule alteration. Please notice that emailSpecification
starts with Optional command that makes the entire model optional (null is allowed) and no error is returned even though both atRequiredSpecification
and allLettersLowerCaseSpecification
require model to be not null. Of course, technically it is NOT a modification of their presence settings, but the specification execution would never reach them. Why? The scope value is null, and the scope presence rule Optional
allows this. And in case of null, as always, no further validation is performed in the scope. Not a big deal, but the example gives an overview of how to play with fluent-api bits to "modify" presence rule.
Naturally, this works the other way around. Below a short demo of how to make a model required while only using specification that allows the model to be null:
Specification<string> emailOptionalSpecification = s => s
.Optional()
.Rule(text => text.Contains('@')).WithMessage("Must contain @ character!");
Specification<string> emailSpecification = s => s
.AsModel(emailOptionalSpecification);
var emailOptionalValidator = Validator.Factory.Create(emailOptionalSpecification);
var emailValidator = Validator.Factory.Create(emailSpecification);
emailOptionalValidator.Validate(null).AnyErrors; // false
emailOptionalValidator.Validate("Email").ToString();
// Must contain @ character!
emailValidator.Validate(null).ToString();
// Required
emailValidator.Validate("Email").ToString();
// Must contain @ character!
As you can notice, null passed to emailOptionalValidator
doesn't produce any validation errors (and it's okay, because the specification allows that with Optional
command). Having the same specification in AsModel
effectively changes this behavior. True, null passed to AsModel
would not return any error output, but null never gets there. The root scope (emailSpecification
) doesn't allow nulls and it terminates the validation before reaching AsModel
.
AsModel
can be very helpful if you want to bundle many commands and want a single error message if any of them indicates validation error.- Saying that,
AsModel
can wrap the entire specification and return single error message out of it. - This is just a regular usage of WithMessage command and applies to all scope commands, not only
AsModel
. It's mentioned here only to present this very specific use case. For more details, please read the WithMessage section.
- Saying that,
Specification<string> emailSpecification = s => s
.Rule(text => text.Contains('@')).WithMessage("Must contain @ character!")
.Rule(text => !text.Any(c => !char.IsLetter(c) || char.IsLower(c))).WithMessage("All letters need to be lower case!")
.Rule(text => text.Length > 5).WithMessage("Must be longer than 5 characters")
.Rule(text => text.Length < 20).WithMessage("Must be shorter than 20 characters");
Specification<string> emailWrapperSpecification = s => s
.AsModel(emailSpecification).WithMessage("This value is invalid as email address");
var emailValidator = Validator.Factory.Create(emailSpecification);
var emailWrapperValidator = Validator.Factory.Create(emailWrapperSpecification);
emailValidator.Validate("Email").ToString();
// Must contain @ character!
// All letters need to be lower case!
// Must be longer than 5 characters
emailWrapperValidator.Validate("Email").ToString();
// This value is invalid as email address
Above, emailSpecification
contains multiple rules and - similarly - can have several messages in its error output. When wrapped within AsModel
followed by WithMessage
command, any validation failure results with just a single error message.
The advantage of this combination is even more visible when you define specification inline and skip all of the error messages attached to the rules - they won't ever be in the output anyway.
Specification<string> emailSpecification = s => s
.AsModel(s1 => s1
.Rule(text => text.Contains('@'))
.Rule(text => !text.Any(c => !char.IsLetter(c) || char.IsLower(c)))
.Rule(text => text.Length > 5)
.Rule(text => text.Length < 20)
).WithMessage("This value is invalid as email address");
var emailValidator = Validator.Factory.Create(emailSpecification);
emailValidator.Validate("Email").ToString();
// This value is invalid as email address
AsCollection
is a scope command.- Can be placed after:
- any command except Forbidden.
- Can be followed by:
- any of the scope commands.
- any of the parameter commands.
- Can be placed after:
AsCollection
command has two generic type parameters:AsCollection<T, TItem>
, where:TItem
- is a type of the single item in the collection.T
- is derived fromIEnumerable<TItem>
.
AsCollection
has dedicated versions for some dotnet native collections, so you don't need to specify a pair ofIEnumerable<TItem>
andTItem
while dealing with:T[]
IEnumerable<T>
ICollection<T>
IReadOnlyCollection<T>
IList<T>
IReadOnlyList<T>
List<T>
AsCollection
accepts one parameter; item specificationSpecification<TItem>
.AsCollection
executes the passed specification upon each item in the collection.- Internally, getting the items out of the collection is done using
foreach
loop.- Validation doesn't materialize the collection. Elements are picked up using enumerator (as in standard
foreach
loop). - So it might get very tricky when you implement IEnumerable yourself; there is no protection against an infinite stream of objects coming from the enumerator, etc.
- Validation doesn't materialize the collection. Elements are picked up using enumerator (as in standard
- Items are validated one after another, sequentially.
- Support for async collection validation is coming in the future releases.
- Internally, getting the items out of the collection is done using
- Error output from the n-th item in the collection is saved under the path
#n
.- The counting starts from zero (the first item in the collection is
0
and its error output will be saved under#0
). - Validation uses the standard
foreach
loop over the collection, so "n-th item" really means "n-th item received from enumerator".- For some types, the results won't be deterministic, simple because the collection itself doesn't guarantee to keep the order. It might happen that the error output saved under path
#1
next time will be saved under#13
. This could be a problem for custom collections or some particular use cases, like instance ofHashSet<TItem>
that gets modified between the two validations. But it will never happen for e.g. array orList<T>
.
- For some types, the results won't be deterministic, simple because the collection itself doesn't guarantee to keep the order. It might happen that the error output saved under path
- The counting starts from zero (the first item in the collection is
Specification<int> evenNumberSpecification = s => s
.Rule(number => (number % 2) == 0).WithMessage("Number must be even");
Specification<int[]> specification = s => s
.AsCollection(evenNumberSpecification);
var validator = Validator.Factory.Create(specification);
var numbers = new[] { 1, 2, 3, 4, 5 };
validator.Validate(numbers).ToString();
// #0: Number must be even
// #2: Number must be even
// #4: Number must be even
AsCollection
is able to automatically resolve the type parameters for array. In this case, AsCollection
is AsCollection<int[], int>
under the hood.
AsCollection
makes sense only if the type validated in the scope is a collection- Well... technically, that's not entirely true, because the only requirement is that it implements
IEnumerable<TItem>
interface. - Code completion tools (IntelliSense, Omnisharp, etc.) will show
AsCollection
as always available, but once inserted you'll need to defineT
andTItem
, so effectively -AsCollection
works only for collections.
- Well... technically, that's not entirely true, because the only requirement is that it implements
Let's consider a custom class holding two collections:
class NumberCollection : IEnumerable<int>, IEnumerable<double>
{
public IEnumerable<int> Ints { get; set; }
public IEnumerable<double> Doubles { get; set; }
IEnumerator<double> IEnumerable<double>.GetEnumerator() => Doubles.GetEnumerator();
IEnumerator<int> IEnumerable<int>.GetEnumerator() => Ints.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable<int>)this).GetEnumerator();
}
You can use AsCollection
to validate an object as a collection of any type; as long as you are able to specify both generic type parameters:
Specification<int> evenNumberSpecification = s => s
.Rule(number => (number % 2) == 0).WithMessage("Number must be even");
Specification<double> smallDecimalSpecification = s => s
.Rule(number => Math.Floor(number) < 0.5).WithMessage("Decimal part must be below 0.5");
Specification<NumberCollection> specification = s => s
.AsCollection<NumberCollection, int>(evenNumberSpecification)
.AsCollection<NumberCollection, double>(smallDecimalSpecification);
var validator = Validator.Factory.Create(specification);
var numberCollection = new NumberCollection()
{
Ints = new [] { 1, 2, 3, 4, 5 },
Doubles = new [] { 1.1, 2.8, 3.3, 4.6, 5.9 }
}
validator.Validate(numberCollection).ToString();
// #0: Number must be even
// #1: Decimal part must be below 0.5
// #2: Number must be even
// #3: Decimal part must be below 0.5
// #4: Number must be even
// #4: Decimal part must be below 0.5
Above, AsCollection
command triggers validation of NumberCollection
as a collection of int
and double
items, each with their own specification.
AsCollection
doesn't treat the null item as anything special. The behavior is described by the passed specification.AsCollection
is like Member command, but the member selector is pointing at the collection items and the path is dynamic.
Specification<AuthorModel> authorSpecification = s => s
.Member(m => m.Email, m => m
.Rule(email => email.Contains('@')).WithMessage("Must contain @ character!")
);
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Authors, m => m.AsCollection(authorSpecification));
var bookValidator = Validator.Factory.Create(bookSpecification);
var book = new BookModel()
{
Authors = new[]
{
null,
new AuthorModel() { Email = "foo@bar" },
new AuthorModel() { Email = null },
null,
new AuthorModel() { Email = "InvalidEmail" },
null,
}
};
bookValidator.Validate(book).ToString();
// Authors.#0: Required
// Authors.#2.Email: Required
// Authors.#3: Required
// Authors.#4.Email: Must contain @ character!
// Authors.#5: Required
In the code above you can see that null items in the collection result with the default error message. This is because authorSpecification
doesn't allow nulls.
Let's change this and see what happens:
Specification<AuthorModel> authorSpecification = s => s
.Optional()
.Member(m => m.Email, m => m
.Rule(email => email.Contains('@')).WithMessage("Must contain @ character!")
);
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Authors, m => m.AsCollection(authorSpecification));
var bookValidator = Validator.Factory.Create(bookSpecification);
var book = new BookModel()
{
Authors = new[]
{
null,
new AuthorModel() { Email = "foo@bar" },
new AuthorModel() { Email = null },
null,
new AuthorModel() { Email = "InvalidEmail" },
null,
}
};
validator.Validate(book).ToString();
// Authors.#2.Email: Required
// Authors.#4.Email: Must contain @ character!
Above, authorSpecification
starts with Optional command, and therefore null items in the collection are allowed.
AsCollection
validates the collection items, but the collection itself (as an object) can be normally validated in its own scope normally, as any other value.- One of the widespread use cases is to verify the collection size:
Specification<AuthorModel> authorSpecification = s => s
.Optional()
.Member(m => m.Email, m => m
.Rule(email => email.Contains('@')).WithMessage("Must contain @ character!")
);
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Authors, m => m
.AsCollection(authorSpecification)
.Rule(authors => authors.Count() <= 5).WithMessage("Book can have max 5 authors.")
);
var bookValidator = Validator.Factory.Create(bookSpecification);
var book = new BookModel()
{
Authors = new[]
{
null,
new AuthorModel() { Email = "foo@bar" },
new AuthorModel() { Email = null },
null,
new AuthorModel() { Email = "InvalidEmail" },
null,
}
};
bookValidator.Validate(book).ToString();
// Authors.#2.Email: Required
// Authors.#4: Must contain @ character!
// Authors: Book can have max 5 authors.
AsNullable
is a scope command.- Can be placed after:
- any command except Forbidden.
- Can be followed by:
- any of the scope commands.
- any of the parameter commands.
- Can be placed after:
AsNullable
"unwraps" the nullable value and provides the way to validate it with a specification.AsNullable
accepts a single parameter;Specification<T>
, whereT
is a value type wrapped inNullable<T>
(T?
).- Null value never reaches
AsNullable
, exactly as handling nulls policy states.- The passed specification describes
T
that is a value type, so Optional command is not even available. - Null must be handled one level higher (in the specification that contains
AsNullable
).
- The passed specification describes
Specification<int> numberSpecification = s => s
.Rule(number => number < 10).WithMessage("Number must be less than 10");
Specification<int?> nullableSpecification = s => s
.AsNullable(numberSpecification);
var validator = Validator.Factory.Create(nullableSpecification);
validator.Validate(5).AnyErrors; // false
validator.Validate(15).ToString();
// Number must be less than 10
validator.Validate(null).ToString();
// Required
In the above code, Validate
method accepts int?
. You can observe that the value is unwrapped by AsNullable
and validated with numberSpecification
(that describes just int
).
If the nullable value is null, it is stopped at the level of nullableSpecification
, which doesn't allow nulls. Of course, you can change this behavior:
Specification<int> numberSpecification = s => s
.Rule(number => number < 10).WithMessage("Number must be less than 10");
Specification<int?> nullableSpecification = s => s
.Optional()
.AsNullable(numberSpecification);
var validator = Validator.Factory.Create(nullableSpecification);
validator.Validate(5).AnyErrors; // false
validator.Validate(null).AnyErrors; // false
validator.Validate(15).ToString();
// Number must be less than 10
Now, nullableSpecification
starts with Optional command, and therefore - null doesn't result with an error. On the other hand - if nullable has a value, it is passed and validated with numberSpecification
.
- Every built-in rule for a value type has an extra variant for the nullable of this type.
- So you don't need to provide
AsNullable
in the most popular and simple cases.
- So you don't need to provide
Specification<int> numberSpecification = s => s.GreaterThan(0).LessThan(10);
Specification<int?> nullableSpecification = s => s.GreaterThan(0).LessThan(10);
var numberValidator = Validator.Factory.Create(numberSpecification);
var nullableValidator = Validator.Factory.Create(nullableSpecification);
numberValidator.Validate(5).AnyErrors; // false
nullableValidator.Validate(5).AnyErrors; // false
numberValidator.Validate(15).ToString();
// Must be less than 10
nullableValidator.Validate(15).ToString();
// Must be less than 10
In the above code, GreaterThan
and LessThan
can be applied to both Specification<int?>
and Specification<int>
. Technically, they are two separate rules with same names. The consistency of their inner logic is verified by the unit tests.
AsNullable
can be handy when you have two versions of the same type (nullable and non-nullable) that can be validated with the same specification.
Specification<int> yearSpecification = s => s
.Rule(year => year >= -3000).WithMessage("Minimum year is 3000 B.C.")
.Rule(year => year <= 3000).WithMessage("Maximum year is 3000 A.D.");
Specification<BookModel> bookSpecification = s => s
.Member(m => m.YearOfFirstAnnouncement, yearSpecification)
.Member(m => m.YearOfPublication, m => m
.Optional()
.AsNullable(yearSpecification)
);
var bookValidator = Validator.Factory.Create(bookSpecification);
var book = new BookModel()
{
YearOfFirstAnnouncement = -4000,
YearOfPublication = 4000
};
bookValidator.Validate(book).ToString()
// YearOfFirstAnnouncement: Minimum year is 3000 B.C.
// YearOfPublication: Maximum year is 3000 A.D.
Above the example how two members - nullable YearOfPublication
and non-nullable YearOfFirstAnnouncement
- can be validated with the same specification yearSpecification
.
AsConverted
is a scope command.- Can be placed after:
- any command except Forbidden.
- Can be followed by:
- any of the scope commands.
- any of the parameter commands.
- Can be placed after:
AsConverted
validates the value as a different value.- It could be a value of the same, or of a different type.
- The type of the specification is determined by the converter's output.
AsConverted
accepts:- A conversion function (of type
System.Converter<in TInput,out TOutput>
) that takes the current scope value and outputs the new value. - A specification for type
TOutput
used to validate the converted value.
- A conversion function (of type
AsConverted
executes the delivered specification within the same scope (so all errors are saved on the same level)- So technically, it could be considered as AsModel, but with a conversion method that's executed upon the scope value before the futher validation.
Below; a snippet presenting how to sanitize the value (for whatever reason that could be an actual case) before validating it with the predefined specification.
Specification<string> nameSpecification = s => s
.Rule(name => char.IsUpper(name.First())).WithMessage("Must start with a capital letter!")
.Rule(name => !name.Any(char.IsWhiteSpace)).WithMessage("Must not contain whitespace!");
Converter<string, string> sanitizeName = firstName => firstName.Trim();
Specification<string> nameValueSpecification = s => s
.AsConverted(sanitizeName, nameSpecification);
var nameValidator = Validator.Factory.Create(nameValueSpecification);
nameValidator.Validate("Bartosz").AnyErrors; // false
nameValidator.Validate(" Bartosz ").AnyErrors; // false
nameValidator.Validate(" bartosz ").ToString();
// Must start with a capital letter!
nameValidator.Validate(" Bart osz ").ToString();
// Must not contain whitespace!
Of course, type can be different. It's the converter's output that determines the specification. Also, both arguments could be delivered inline:
Specification<AuthorModel> authorSpecification = s => s
.Member(m => m.Name, m => m.AsConverted(
name => name.Length,
nameLength => nameLength.Rule(l => l % 2 == 0).WithMessage("Characters amount must be even"))
);
var nameValidator = Validator.Factory.Create(authorSpecification);
var author = new AuthorModel()
{
Name = "Bartosz"
};
nameValidator.Validate(author).ToString();
// Name: Characters amount must be even
- The template will contain all errors from the delivered specification, which could lead to misleading case in which the "Required" error is listed as a possible outcome for a value type.
- This happens when a value type is converted to a reference type.
- If you want to "fix" te template, add Optional at the beginning in the converted value's specification.
Specification<int> specification1 = s => s
.AsConverted(
v => v.ToString(CultureInfo.InvariantCulture),
c => c.MaxLength(10).WithMessage("Number must be max 5 digits length")
);
Validator.Factory.Create(specification1).Template.ToString();
// Required
// Number must be max 5 digits length
Specification<int> specification2 = s => s
.AsConverted(
v => v.ToString(CultureInfo.InvariantCulture),
c => c.Optional().MaxLength(10).WithMessage("Number must be max 5 digits length")
);
Validator.Factory.Create(specification2).Template.ToString();
// Number must be max 5 digits length
AsType
is a scope command.- Can be placed after:
- any command except Forbidden.
- Can be followed by:
- any of the scope commands.
- any of the parameter commands.
- Can be placed after:
AsType
validates the value as if it was of a different type.- If the value can be cast into the target type (using
is
/as
operators), the validation proceeds with the given specifiction. - If the value can't be cast (
is
check returns false), nothing happens. No error output is recorded and the validation continues with the subsequent commands.
- If the value can be cast into the target type (using
AsType
accepts:- A specification for type
TTarget
used to validate the cast value.
- A specification for type
AsType
executes the delivered specification within the same scope (so all errors are saved on the same level)- So technically
.AsType(targetTypeSpecification)
, it could be considered as a shortcut for AsConverted command combined with WithCondition:.AsConverted(v => v as TargetType, targetTypeSpecification).WithCondition(v => v is TargetType)
.
- So technically
Let's use the classic inheritance example, like: Animal -> Mammal -> Elephant
:
class Animal
{
public int AnimalId { get; set; }
}
class Mammal : Animal
{
public int MammalId { get; set; }
}
class Elephant : Mammal
{
public int ElephantId { get; set; }
}
Contructing validator for the class at the bottom of the inheritance graph (Elephant
in this case), you can use AsType
and apply specifiction of any of its ancestors:
Specification<int> idSpecification = s => s.NonZero();
Specification<Animal> animalSpecification = s => s
.Member(m => m.AnimalId, idSpecification);
Specification<Elephant> elephantSpecification = s => s
.Member(m => m.ElephantId, idSpecification)
.AsType(animalSpecification);
var elephantValidator = Validator.Factory.Create(elephantSpecification);
elephantValidator.Validate(new Elephant() { ElephantId = 10, AnimalId = 10 }).AnyErrors; // false
elephantValidator.Validate(new Elephant() { ElephantId = 0, AnimalId = 10 }).ToString();
// ElephantId: Must not be zero
elephantValidator.Validate(new Elephant() { ElephantId = 10, AnimalId = 0 }).ToString();
// AnimalId: Must not be zero
It works also in opposite direction. You can create a validator for the ancestor type and use descendants' specifications:
Specification<int> idSpecification = s => s.NonZero();
Specification<Elephant> elephantSpecification = s => s
.Member(m => m.ElephantId, idSpecification);
Specification<Animal> animalSpecification = s => s
.Member(m => m.AnimalId, idSpecification)
.AsType(elephantSpecification);
var animalValidator = Validator.Factory.Create(animalSpecification);
animalValidator.Validate(new Elephant() { ElephantId = 10, AnimalId = 10 }).AnyErrors; // false
animalValidator.Validate(new Elephant() { ElephantId = 0, AnimalId = 10 }).ToString();
// ElephantId: Must not be zero
animalValidator.Validate(new Elephant() { ElephantId = 10, AnimalId = 0 }).ToString();
// AnimalId: Must not be zero
AsType
executes only if the type can be cast (value is TTargetType
is true), so you can use specifiction of unrelated types if for whatever reason you need something that works like a validation hub. Notice that you can construct the specification inline as well (but it's handy to do it with a constructor notation so the compiler can pick up the types from it):
Specification<object> specification = s => s
.AsType(new Specification<int>(number => number.NonZero()))
.AsType(new Specification<string>(text => text.NotEmpty()));
var validator = Validator.Factory.Create(specification);
validator.Validate(12).AnyErrors // false
validator.Validate("test").AnyErrors // false
validator.Validate(0L).AnyErrors // false, because it's not an integer
validator.Validate(0).ToString();
// Must not be zero
validator.Validate("").ToString();
// Must not be empty
Naturally, errors from all levels are ultimately grouped by the paths in the report. Below the example of the one containing messages from all three levels:
Specification<int> idSpecification = s => s.NonZero();
Specification<Animal> animalSpecification = s => s
.Member(m => m.AnimalId, idSpecification);
Specification<Mammal> mammalSpecification = s => s
.Member(m => m.MammalId, idSpecification)
.And()
.Member(m => m.AnimalId, idSpecification)
.WithMessage("Something wrong with animal from mammal perspective")
.And()
.AsType(animalSpecification);
Specification<Elephant> elephantSpecification = s => s
.Member(m => m.ElephantId, idSpecification)
.And()
.Member(m => m.MammalId, idSpecification)
.WithMessage("Something wrong with mammal from elephant perspective")
.And()
.Member(m => m.AnimalId, idSpecification)
.WithMessage("Something wrong with animal from elephant perspective")
.And()
.AsType(mammalSpecification);
var elephantValidator = Validator.Factory.Create(elephantSpecification);
elephantValidator.Validate(new Elephant() { ElephantId = 10, MammalId = 10, AnimalId = 10 }).AnyErrors; // false
elephantValidator.Validate(new Elephant() { ElephantId = 0, MammalId = 10, AnimalId = 10 }).ToString();
// ElephantId: Must not be zero
elephantValidator.Validate(new Elephant() { ElephantId = 10, MammalId = 0, AnimalId = 10 }).ToString();
// MammalId: Must not be zero
// MammalId: Something wrong with mammal from elephant perspective
elephantValidator.Validate(new Elephant() { ElephantId = 0, MammalId = 0, AnimalId = 0 }).ToString();
// ElephantId: Must not be zero
// MammalId: Must not be zero
// MammalId: Something wrong with mammal from elephant perspective
// AnimalId: Must not be zero
// AnimalId: Something wrong with animal from mammal perspective
// AnimalId: Something wrong with animal from elephant perspective
WithCondition
is a parameter command.- Can be placed after:
- the related scope command.
- Can be followed by:
- any of the scope commands.
- other parameter commands: WithPath, WithMessage, WithExtraMessage, WithCode, WithExtraCode.
- Can be placed after:
WithCondition
sets the execution condition to the related (preceding) scope command.WithCondition
accepts single argument; a predicatePredicate<T>
, whereT
is the current scope type.- So
T
is the same as inSpecification<T>
where the command lives. - The received argument is never null.
- So
- If the predicate returns:
true
- the related scope command is going to be executed.- same behavior as if
When
wasn't there at all.
- same behavior as if
false
- the related scope command is skipped.- no validation logic defined in the scope command is triggered.
- no error output is returned.
Predicate<string> isValidEmail = email => email.Substring(0, email.IndexOf('@')).All(char.IsLetterOrDigit);
Specification<string> emailSpecification = s => s
.Rule(isValidEmail)
.WithCondition(email => email.Contains('@'))
.WithMessage("Email username must contain only letters and digits.");
var validator = Validator.Factory.Create(emailSpecification);
validator.Validate("John.Doe-at-gmail.com").AnyErrors; // false
validator.Validate("John.Doe@gmail.com").ToStringMessages();
// Email username must contain only letters and digits.
Above, the predicate in WithCondition
checks if the scope value contains @
character. If true, then the related command scope (Rule) is executed.
The code shows also that WithCondition
can makes the code look more clean and readable, as isValidEmail
predicate doesn't need to contain any logic around email.IndexOf('@')
returning -1
. It always has @
at some position, because otherwise the condition in WithCondition
prevents the entire Rule
scope from execution.
WithCondition
can be used in pre-verification.- Example; it can ensure that all elements are non-null before validating the relation between them.
Predicate<BookModel> isAuthorAPublisher = book =>
{
return book.Authors.Any(a => a.Name == book.Publisher.Name);
};
Specification<BookModel> bookSpecification = s => s
.Rule(isAuthorAPublisher)
.WithCondition(book =>
book.IsSelfPublished &&
book.Authors?.Any() == true &&
book.Publisher?.Name != null
)
.WithMessage("Self-published book must have author as a publisher.");
var validator = Validator.Factory.Create(bookSpecification);
// 1: Condition is met, but the rule fails:
var bookModel1 = new BookModel()
{
IsSelfPublished = true,
Authors = new[] { new AuthorModel() { Name = "Bart" } },
Publisher = new PublisherModel() { Name = "Adam" }
};
// 2: Condition is met, and the rule doesn't fail:
var bookModel2 = new BookModel()
{
IsSelfPublished = true,
Authors = new[] { new AuthorModel() { Name = "Bart" } },
Publisher = new PublisherModel() { Name = "Bart" }
};
// 3: Condition is not met:
var bookModel3 = new BookModel()
{
IsSelfPublished = false,
Authors = new[] { new AuthorModel() { Name = "Bart" } },
Publisher = null
};
validator.Validate(bookModel1).ToString();
// Self-published book must have author as a publisher.
validator.Validate(bookModel2).AnyErrors; // false
validator.Validate(bookModel3).AnyErrors; // false
Validot never passes null into predicates, but in the above code isAuthorAPublisher
doesn't care at all about null also at the nested levels (Publisher
and Publisher.Name
). The logic in WithCondition
makes sure that the values are always going to be there.
WithCondition
allows you to define many specifications (each validating different case) and execute them selectively, based on some logic. Either exclusively (one at the time) or using any way of mixing them.
Specification<string> gmailSpecification = s => s
.Rule(email => {
var username = email.Substring(0, email.Length - "@gmail.com".Length);
return !username.Contains('.');
}).WithMessage("Gmail username must not contain dots.");
Specification<string> outlookSpecification = s => s
.Rule(email => {
var username = email.Substring(0, email.Length - "@outlook.com".Length);
return username.All(char.IsLower);
}).WithMessage("Outlook username must be all lower case.");
Specification<string> emailSpecification = s => s
.Rule(email => email.Contains('@')).WithMessage("Must contain @ character!");
Predicate<AuthorModel> hasGmailAddress = a => a.Email?.EndsWith("@gmail.com") == true;
Predicate<AuthorModel> hasOutlookAddress = a => a.Email?.EndsWith("@outlook.com") == true;
Specification<AuthorModel> authorSpecification = s => s
.Member(m => m.Email, gmailSpecification).WithCondition(hasGmailAddress)
.Member(m => m.Email, outlookSpecification).WithCondition(hasOutlookAddress)
.Member(m => m.Email, emailSpecification)
.WithCondition(author => !hasGmailAddress(author) && !hasOutlookAddress(author));
var validator = Validator.Factory.Create(authorSpecification);
var outlookAuthor = new AuthorModel() { Email = "John.Doe@outlook.com" };
var gmailAuthor = new AuthorModel() { Email = "John.Doe@gmail.com" };
var author1 = new AuthorModel() { Email = "JohnDoe" };
var author2 = new AuthorModel() { Email = "John.Doe@yahoo.com" };
validator.Validate(outlookAuthor).ToString();
// Email: Outlook username must be all lower case.
validator.Validate(gmailAuthor).ToString();
// Email: Gmail username must not contain dots.
validator.Validate(author1).ToString();
// Email: Must contain @ character!
validator.Validate(author2).AnyErrors; // false
The above code shows how to validate a member with three different specifications, depending on the the email provider.
WithPath
is a parameter command.- Can be placed after:
- the related scope command.
- other parameter commands: WithCondition.
- Can be followed by:
- any of the scope commands.
- other parameter commands: WithMessage, WithExtraMessage, WithCode, WithExtraCode.
- Can be placed after:
WithPath
sets the path for the related scope's error output.WithPath
accepts one parameter; a path relative to the current scope path.- Example 1; at
FirstLevel.SecondLevel
, settingThirdLevel
as path results withFirstLevel.SecondLevel.ThirdLevel
, not justThirdLevel
. - Example 2; at root level, placing setting
Characters
as path results withCharacters
:
- Example 1; at
Specification<string> specification1 = s => s
.Rule(email => email.Contains('@'))
.WithMessage("Must contain @ character!");
Specification<string> specification2 = s => s
.Rule(email => email.Contains('@'))
.WithPath("Characters")
.WithMessage("Must contain @ character!");
var validator1 = Validator.Factory.Create(specification1);
var validator2 = Validator.Factory.Create(specification2);
validator1.Validate("invalidemail").ToStringMessages();
// Must contain @ character!
validator2.Validate("invalidemail").ToStringMessages();
// Characters: Must contain @ character!
You can observe that the error output coming from the Rule
scope command is saved under Characters
path.
WithPath
can move the error output between levels:- To move it down to the nested level, just use
.
(dot) as a separator, e.g.FirstLevel.SecondLevel
- Effectively it works like appending the path to the current one.
- To move to the upper level, place as many
<
(less-than) as many levels you want to go up- Single
<
works and moves the error output one level up. - Passing
<<<
would move the error output three levels up, etc.
- Single
- To move it to the upper level, and to the nested level (but e.g. different branch), combine the two methods described above.
- Passing
<<Test
would go two levels up and then step intoTest
- Going up always stops at the root level, so don't worry if you put too many of
<
.- This wouldn't result with an exception, but it could be very misleading if you use such specification in another specification. Please be careful because Validot won't warn you about this problem.
- Passing
- To move it down to the nested level, just use
Current path | WithPath parameter | Final path |
---|---|---|
root level | FirstLevel |
FirstLevel |
root level | FirstLevel.SecondLevel |
FirstLevel.SecondLevel |
FirstLevel |
SecondLevel |
FirstLevel.SecondLevel |
FirstLevel |
SecondLevel.ThirdLevel |
FirstLevel.SecondLevel.ThirdLevel |
FirstLevel.SecondLevel.ThirdLevel |
< |
FirstLevel.SecondLevel |
FirstLevel.SecondLevel.ThirdLevel |
<< |
FirstLevel |
FirstLevel.SecondLevel.ThirdLevel |
<<< |
root level |
FirstLevel.SecondLevel.ThirdLevel |
<3rdLvl |
FirstLevel.SecondLevel.3rdLvl |
FirstLevel.SecondLevel.ThirdLevel |
<<2ndLvl |
FirstLevel.2ndLvl |
FirstLevel.SecondLevel.ThirdLevel |
<<<1stLvl |
1stLvl |
FirstLevel.SecondLevel.ThirdLevel |
<<<1stLvl.2ndLvl |
1stLvl.2ndLvl |
FirstLevel.SecondLevel.ThirdLevel |
<<<< |
root level |
FirstLevel.SecondLevel.ThirdLevel |
<<<<<< |
root level |
FirstLevel |
<<<<<< |
root level |
root level | <<<<<< |
root level |
root level | <<<<<<FirstLevel |
FirtLevel |
FirstLevel.SecondLevel.ThirdLevel |
<<<<<<A.B.C |
A.B.C |
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Publisher, m => m
.Member(m1 => m1.Name, m1 => m1
.Rule(name => name.All(char.IsLetter)).WithPath("<<NameOfPublisher").WithMessage("Must consist of letters only!")
)
);
var bookValidator = Validator.Factory.Create(bookSpecification);
var book = new BookModel()
{
Publisher = new PublisherModel()
{
Name = "Adam !!!"
}
};
bookValidator.Validate(book).ToString();
// NameOfPublisher: Must consist of letters only!
Rule would normally save the message within its scope's path (Publisher.Name
), but <<NameOfPublisher
moves its location two levels up and then down to NameOfPublisher
- Path passed to
WithPath
has few restrictions related to.
(dot) being a special character used to separate levels:- you can't start the path with
.
- you can't end the path with
.
- you can't have two dots next to each other (
..
)
- you can't start the path with
Specification<string> specification = s => s
.Rule(email => email.Contains('@'))
.WithPath("Characters.")
.WithMessage("Must contain @ character!");
var validator = Validator.Factory.Create(specification); // throws ArgumentExceptions
WithPath
is often used to configure Member command.- By default, Member uses member selector to resolve the next level where the error output from the passed specification will be saved under.
- So if the member selector is
m => m.DescriptionDetails
, then by default the error output is saved underDescriptionDetails
- So if the member selector is
WithPath
can alter this default value.
- By default, Member uses member selector to resolve the next level where the error output from the passed specification will be saved under.
Specification<PublisherModel> publisherSpecification = s => s
.Member(m => m.Name, nameSpecification).WithPath("FirstName");
var publisherValidator = Validator.Factory.Create(publisherSpecification);
var publisher = new PublisherModel()
{
Name = "Adam !!!"
};
publisherValidator.Validate(publisher).ToString();
// FirstName: Must consist of letters only!
// FirstName: Must not contain whitespace!
The default location set by the Member command - Name
- has been changed to FirstName
.
WithPath
can be used to merge error outputs from many scopes into a single path.
Specification<string> nameSpecification = s => s
.Rule(name => name.All(char.IsLetter)).WithMessage("Name must consist of letters only!")
.Rule(name => !name.Any(char.IsWhiteSpace)).WithMessage("Name must not contain whitespace!");
Specification<string> companyIdSpecification = s => s
.Rule(name => name.Any()).WithMessage("Company Id must not be empty!");
Specification<PublisherModel> publisherSpecification = s => s
.Member(m => m.Name, nameSpecification).WithPath("<Info")
.Member(m => m.CompanyId, companyIdSpecification).WithPath("<Info");
var publisherValidator = Validator.Factory.Create(publisherSpecification);
var publisher = new PublisherModel()
{
Name = "Adam !!!",
CompanyId = ""
};
publisherValidator.Validate(publisher).ToString();
// Info: Name must consist of letters only!
// Info: Name must not contain whitespace!
// Info: Company Id must not be empty!
Error messages from two scopes (members Name
and CompanyId
) are both placed under Info
path.
WithPath
can be used to split error output and distribute errors from a single scope into distinct paths.
Specification<string> nameSpecification = s => s
.Rule(name => name.All(char.IsLetter))
.WithPath("Characters")
.WithMessage("Must consist of letters only!")
.Rule(name => char.IsUpper(name.First()))
.WithPath("Grammar")
.WithMessage("First letter must be capital!");
Specification<PublisherModel> publisherSpecification = s => s
.Member(m => m.Name, nameSpecification);
var publisherValidator = Validator.Factory.Create(publisherSpecification);
var publisher = new PublisherModel()
{
Name = "adam !!!",
};
publisherValidator.Validate(publisher).ToString();
// Name.Characters: Must consist of letters only!
// Name.Grammar: First letter must be capital!
Above, two rules from the same scope are saving error messages into entirely different paths (Characters
and Grammar
).
WithMessage
is a parameter command.- Can be placed after:
- the related scope command.
- the related presence commands: Required, Forbidden.
- other parameter commands: WithCondition, WithPath.
- Can be followed by:
- any of the scope commands.
- other parameter commands: WithExtraMessage, WithExtraCode.
- Can be placed after:
WithMessage
overwrites the entire error output of the related (preceding) scope command with an error output that contains a single error message.- Effectively it's overwriting all errors with a single message.
WithMessage
accepts single parameters: message content.WithMessage
is the only way to override the default message ("Error"
) recorded if the predicate in Rule fails:
Specification<int> specification = s => s
.Rule(year => year != 0);
var validator = Validator.Factory.Create(specification);
validator.Validate(0).ToString();
// Error
Specification<int> specificationWithMessage = s => s
.Rule(year => year != 0)
.WithMessage("Year 0 is invalid");
var validatorWithMessage = Validator.Factory.Create(specificationWithMessage);
Validator.Factory.Create(specificationWithMessage).Validate(0).ToString();
// Year 0 is invalid
- It doesn't matter how many nested levels or messages/codes the error output has. If any of the inner validation rules indicates failure, the entire related scope returns a single message passed to
WithMessage
.- If there is no error - there is no error output, and of course, no message as well.
Specification<AuthorModel> authorSpecification = s => s.Member(m => m.Email, m => m.Email());
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Authors, m => m
.AsCollection(authorSpecification).WithMessage("Contains author with invalid email")
);
var validator = Validator.Factory.Create(bookSpecification);
var book = new BookModel()
{
Authors = new[]
{
new AuthorModel() { Email = "InvalidEmail1" },
new AuthorModel() { Email = "InvalidEmail2" },
new AuthorModel() { Email = "john.doe@gmail.com" },
new AuthorModel() { Email = "InvalidEmail3" },
}
};
validator.Validate(book).ToString();
// Authors: Contains author with invalid email
Above, AsCollection would return messages under multiple different paths. When followed by WithMessage
even a single error coming from AsCollection results with just a single error message.
- When overwriting the error output of RuleTemplate,
WithMessage
has full access to their message arguments and can use them in its content.- Good to read;
- built-in rules - list of the rules and the arguments available
- message args - how to use args and placeholders
- Good to read;
Specification<int> specification = s => s
.Between(min: 10, max: 20)
.WithMessage("Minimum value is {min}. Maximum value is {max}");
var validator = Validator.Factory.Create(specification);
validator.Validate(0).ToString();
// Minimum value is 10. Maximum value is 20
Between
rule takes two arguments; max
and min
. These values can be used within the message - just use the placeholders.
WithMessage
combined with AsModel can be used to group multiple rules and define one error message for them.- Good to read: AsModel - in this section, you can find code example for such a scenario.
- Validation result presents messages in:
- ToString - prints messages preceded by their paths, each in a separate line.
- MessageMap - a dictionary that holds collections of messages grouped by the paths.
WithExtraMessage
is a parameter command.- Can be placed after:
- the related scope command.
- the related presence commands: Required, Forbidden.
- other parameter commands: WithCondition, WithPath, WithMessage.
- Can be followed by:
- any of the scope commands.
- other parameter commands: WithExtraMessage, WithExtraCode.
- Can be placed after:
WithExtraMessage
adds a single message to the error output of the related scope.WithExtraMessage
accepts a single parameter: message content.WithExtraMessage
is the only way to add additional messages to the error output.WithExtraMessage
can be used multiple times, in a row:
Specification<int> specification = s => s
.Rule(year => year != 0)
.WithMessage("Year 0 is invalid")
.WithExtraMessage("Year 0 didn't exist")
.WithExtraMessage("Please change to 1 B.C. or 1 A.D.");
var validator = Validator.Factory.Create(specification);
validator.Validate(0).ToString();
// Year 0 is invalid
// Year 0 didn't exist
// Please change to 1 B.C. or 1 A.D.
WithExtraMessage
acts very similar to WithMessage, with one important difference; in case of error, it appends message to the error output of the related scope, instead of overwriting it (as WithMessage would do).- Message is added only if the related scope has error output. No error output - no extra message.
Specification<AuthorModel> authorSpecification = s => s.Member(m => m.Email, m => m.Email());
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Authors, m => m
.AsCollection(authorSpecification).WithExtraMessage("Contains author with invalid email")
);
var validator = Validator.Factory.Create(bookSpecification);
var book = new BookModel()
{
Authors = new[]
{
new AuthorModel() { Email = "InvalidEmail1" },
new AuthorModel() { Email = "InvalidEmail2" },
new AuthorModel() { Email = "john.doe@gmail.com" },
new AuthorModel() { Email = "InvalidEmail3" },
}
};
validator.Validate(book).ToString();
// Authors.#0.Email: Must be a valid email address
// Authors.#1.Email: Must be a valid email address
// Authors.#3.Email: Must be a valid email address
// Authors: Contains author with invalid email
A similar example to the above one is in the WithMessage section. Here, AsCollection command returns messages under multiple different paths. When followed by WithExtraMessage
even a single error coming from AsCollection results with an extra message appended to the entire scope.
- When overwriting the error output of RuleTemplate,
WithMessage
has full access to their message arguments and can use them in its content.- Good to read;
- built-in rules - a list of the rules and the arguments available.
- message arguments - how to use args and placeholders.
- Good to read;
Specification<int> specification = s => s
.Between(min: 10, max: 20)
.WithExtraMessage("Minimum value is {min}. Maximum value is {max}.");
var validator = Validator.Factory.Create(specification);
validator.Validate(0).ToString();
// Must be between 10 and 20 (exclusive)
// Minimum value is 10. Maximum value is 20.
Between
rule takes two arguments; max
and min
. These values can be used within the message set with both WithMessage and WithExtraMessage - just use the placeholders.
- Validation result presents messages in:
- ToString - prints messages preceded by their paths, each in a separate line.
- MessageMap - a dictionary that holds collections of messages grouped by the paths.
WithCode
is a parameter command.- Can be placed after:
- the related scope command.
- the related presence commands: Required, Forbidden.
- other parameter commands: WithCondition, WithPath.
- Can be followed by:
- any of the scope commands.
- other parameter commands: WithExtraCode.
- Can be placed after:
WithCode
overwrites the entire output of the related scope with a single error code.WithCode
accepts one parameter: code.- Error code can't contain white space characters.
WithCode
acts very similar to WithMessage, with one important difference; in case of error, it overrides the entire error output with the error code, instead of the error message (as WithMessage would do).- Error code is only if the related scope has any error output. No error output - no error code.
- The entire error output is overridden, including the messages! If you want to have both messages AND codes, you should use WithExtraCode command.
Specification<int> specification = s => s
.Rule(year => year != 0)
.WithCode("YEAR_ZERO");
var validator = Validator.Factory.Create(specification);
var result = validator.Validate(0);
result.ToString();
// YEAR_ZERO
Normally, Rule would return error message, but in the above code, the entire error output is replaced with a single code.
- Validation result presents codes in:
- Codes - a collection of all error codes, from all paths, without duplications.
- ToString() - prints all the codes from Codes collection in the first line, coma separated.
- CodeMap - a dictionary that holds collections of codes grouped by the paths.
Specification<int[]> specification = s => s
.AsCollection(m => m
.Rule(year => year % 2 == 0).WithCode("IS_EVEN")
.Rule(year => year % 2 != 0).WithCode("IS_ODD")
);
var validator = Validator.Factory.Create(specification);
var result = validator.Validate(new[] { 0, 1, 2, 3, 4 });
result.ToString();
// IS_EVEN, IS_ODD
result.Codes; // collection containing two items:
// ["IS_EVEN", "IS_ODD"]
result.CodeMap["#0"]; // collection with single item: ["IS_EVEN"]
result.CodeMap["#1"]; // collection with single item: ["IS_ODD"]
result.CodeMap["#2"]; // collection with single item: ["IS_EVEN"]
result.CodeMap["#3"]; // collection with single item: ["IS_ODD"]
result.CodeMap["#4"]; // collection with single item: ["IS_EVEN"]
In the above example, ToString prints all error codes in the first line. Codes contains all the codes and CodeMap allows to check exactly where the codes has been recorded.
WithCode
can be used to group multiple rules and define one code for any failure among them.
Specification<AuthorModel> authorSpecification = s => s.Member(m => m.Email, m => m.Email());
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Authors, m => m
.AsCollection(authorSpecification).WithCode("INVALID_AUTHORS")
);
var validator = Validator.Factory.Create(bookSpecification);
var book = new BookModel()
{
Authors = new[]
{
new AuthorModel() { Email = "InvalidEmail1" },
new AuthorModel() { Email = "InvalidEmail2" },
new AuthorModel() { Email = "john.doe@gmail.com" },
new AuthorModel() { Email = "InvalidEmail3" },
}
};
validator.Validate(book).ToString();
// INVALID_AUTHORS
result.Codes; // collection with single item: ["INVALID_AUTHORS"]
result.CodeMap["Authors"]; // collection with single item: ["INVALID_AUTHORS"]
Above, AsCollection would return messages under multiple different paths. When followed by WithCode
even a single error coming from AsCollection results with just a single error code.
WithExtraCode
is a parameter command.- Can be placed after:
- the related scope command.
- the related presence commands: Required, Forbidden.
- other parameter commands: WithCondition, WithPath, WithMessage, WithExtraMessage, WithCode.
- Can be followed by:
- any of the scope commands.
- other parameter commands: WithExtraCode.
- Can be placed after:
WithExtraCode
adds a single error code to the error output of the related (preceding) command scope.WithExtraCode
accepts a single parameter; code.- Reminder; error code can't contain white space characters.
WithExtraCode
is for WithCode what WithExtraMessage is for WithMessage.
Specification<int> specification = s => s
.Rule(year => year != 0)
.WithCode("YEAR_ZERO")
.WithExtraCode("INVALID_YEAR");
var validator = Validator.Factory.Create(specification);
var result = validator.Validate(0);
result.ToString();
// YEAR_ZERO, INVALID_YEAR
WithExtraCode
acts very similar to WithCode, with one important difference; in case of error it appends the error code to the error output of the related scope, instead of overwriting it (as WithCode would do).- Error code is added only if the related scope has error output. No error output - no extra code.
WithExtraCode
is the only way to mix error messages and codes in one error output:
Specification<AuthorModel> authorSpecification = s => s.Member(m => m.Email, m => m.Email());
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Authors, m => m
.AsCollection(authorSpecification).WithExtraCode("INVALID_AUTHORS")
);
var validator = Validator.Factory.Create(bookSpecification);
var book = new BookModel()
{
Authors = new[]
{
new AuthorModel() { Email = "InvalidEmail1" },
new AuthorModel() { Email = "InvalidEmail2" },
new AuthorModel() { Email = "john.doe@gmail.com" },
new AuthorModel() { Email = "InvalidEmail3" },
}
};
var result = validator.Validate(book);
result.Codes; // collection with single item: ["INVALID_AUTHORS"]
result.CodeMap["Authors"]; // collection with single item: ["INVALID_AUTHORS"]
result.ToString();
// INVALID_AUTHORS
//
// Authors.#0.Email: Must be a valid email address
// Authors.#1.Email: Must be a valid email address
// Authors.#3.Email: Must be a valid email address
In the above example, you can observe how [ToString()][#tostring] prints codes and messages. Of course, both can be detaily examined using Codes, CodeMap, and MessageMap properties of validation result.
Optional
is a presence command.- Needs to be placed as the first on in the scope.
- Can be followed by:
- any of the scope commands.
Optional
makes the current scope value optional (null is allowed).Optional
is the only way to avoid errors in case of null scope value.
Specification<string> specification1 = s => s
.Optional()
.Rule(title => title.Length > 3)
.WithMessage("The minimum length is 3");
var validator1 = Validator.Factory.Create(specification1);
validator1.Validate(null).AnyErrors; // false
Above, Optional
placed as the first command in the specification makes null a valid case. If we remove it, the null value will result with validation error:
Specification<string> specification2 = s => s
.Rule(title => title.Length > 3)
.WithMessage("The minimum length is 3");
var validator2 = Validator.Factory.Create(specification2);
var result2 = validator2.Validate(null);
result2.AnyErrors; // true
result2.ToString();
// Required
In both cases (with and without Optional
), when the value is provided - there is no difference in the error output:
validator1.Validate("a").ToString();
// The minimum length is 3
validator2.Validate("a").ToString();
// The minimum length is 3
validator1.Validate("abc").AnyErrors; // false
validator2.Validate("abc").AnyErrors; // false
- Using presence commands in the root scope is absolutely correct, but the most common use case for
Optional
is marking members as optional:
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Title, m => m
.Optional()
.Rule(title => title.Length > 3).WithMessage("The minimum length is 3")
);
var validator = Validator.Factory.Create(bookSpecification);
var book1 = new BookModel() { Title = null };
validator.Validate(book1).AnyErrors; // false
var book2 = new BookModel() { Title = "a" };
validator.Validate(book2).ToString();
// Title: The minimum length is 3
- Good to read; null policy - the entire logic of handling nulls.
Required
is a presence command.- Needs to be placed as the first in the scope.
- Can be followed by:
- any of the scope commands.
- parameter commands: WithMessage, WithExtraMessage, WithCode, WithExtraCode.
Required
makes the current scope value required (null is not allowed).- Every scope by default requires the incoming value to be non-null, and inserting single
Required
doesn't change anything:
- Every scope by default requires the incoming value to be non-null, and inserting single
Specification<string> specification1 = s => s
.Required()
.Rule(title => title.Length > 3)
.WithMessage("The minimum length is 3");
var validator1 = Validator.Factory.Create(specification1);
var result1 = validator1.Validate(null);
result1.AnyErrors; // true
result1.ToString();
// Required
Above, Required
placed as the first command in the specification. If we remove it, literally nothing changes:
Specification<string> specification2 = s => s
.Rule(title => title.Length > 3)
.WithMessage("The minimum length is 3");
var validator2 = Validator.Factory.Create(specification2);
var result2 = validator2.Validate(null);
result2.AnyErrors; // true
result2.ToString();
// Required
Similarly to Optional, in both cases (with and without Required
), when the value is provided - there is no difference ns the error output:
validator1.Validate("a").ToString();
// The minimum length is 3
validator2.Validate("a").ToString();
// The minimum length is 3
validator1.Validate("abc").AnyErrors; // false
validator2.Validate("abc").AnyErrors; // false
Required
can be used to modify the error output that the scope returns if the scope value is null.- WithMessage overrides the default error message.
- WithExtraMessage adds the error message to the default one.
- WithCode overrides the default error message with error code.
- WithExtraCode adds the error code to the default error output.
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Title, m => m
.Required().WithMessage("Title is required").WithExtraCode("MISSING_TITLE")
.Rule(title => title.Length > 3).WithMessage("The minimum length is 3")
);
var validator = Validator.Factory.Create(bookSpecification);
var book = new BookModel() { Title = null };
var result = validator.Validate(book);
result.Codes; // collection with single item: ["MISSING_TITLE"]
result.ToString();
// MISSING_TITLE
//
// Title: Title is required
_Above, Title
member has the default error replaced with message Title is required
and additional code MISSING_TITLE
.
- Presence errors are special, and you can't move them with WithPath, but there are workarounds:
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Title, m => m
.Optional()
.Rule(title => title.Length > 3).WithMessage("The minimum length is 3")
)
.Rule(m => m.Title != null)
.WithPath("BookTitle")
.WithMessage("Title is required")
.WithExtraCode("MISSING_TITLE");
var validator = Validator.Factory.Create(bookSpecification);
var book = new BookModel() { Title = null };
var result = validator.Validate(book);
result.Codes; // [ "MISSING_TITLE" ]
result.ToString();
// MISSING_TITLE
//
// BookTitle: Title is required
Above, Title
is optional, so no presence error is saved under Title
path. If Title
is null, the error output from Rule is saved under BookTitle
path.
- Good to read; null policy - the entire logic of handling nulls.
Forbidden
is a presence command.- Needs to be placed as the first on in the scope.
- Can be followed by:
Forbidden
makes the current scope forbidden.- Non-null is not allowed, or in other words, the value must be null.
Forbidden
is exactly opposite to Required.
Specification<string> specification = s => s
.Forbidden();
var validator = Validator.Factory.Create(specification);
validator.Validate(null).AnyErrors; // false
validator.Validate("some value").ToString();
// Forbidden
- Similarly to Required, you can alter the default error output using parameter commands:
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Title, m => m
.Forbidden().WithMessage("Title will be autogenerated").WithExtraCode("TITLE_EXISTS")
);
var validator = Validator.Factory.Create(bookSpecification);
var book = new BookModel() { Title = null };
var result = validator.Validate(book);
result.Codes; // [ "TITLE_EXISTS" ]
result.ToString();
// TITLE_EXISTS
//
// Title: Title will be autogenerated
- Good to read; null policy - the entire logic of handling nulls.
And
contains no validation logic, it's purpose is to visually separate rules in the fluent API method chain.And
is a special case - from the technical point of view,And
could be described as a scope command that doesn't do anything.- The only difference between
And
and a Rule that doesn't do anything are the position restrictions:And
can't be placed at the beginning of the specification.And
can't be placed at the end of the specification.
And
helps with automatic formatters that could visually spoil the code:
Specification<BookModel> bookSpecificationPlain = s => s
.Member(m => m.Title, m => m
.Optional()
.Rule(title => title.Length > 5)
.WithMessage("The minimum length is 5")
.Rule(title => title.Length < 10)
.WithMessage("The maximum length is 10")
)
.Rule(m => !m.Title.Contains("title"))
.WithPath("Title")
.WithCode("TITLE_IN_TITLE")
.Rule(m => m.YearOfFirstAnnouncement < 3000)
.WithMessage("Maximum year value is 3000");
Above, the example of specification where fluent API methods are separated using indentations. Autoformatting (e.g., when pasting this code) could align all methods like this:
Specification<BookModel> bookSpecificationPlain = s => s
.Member(m => m.Title, m => m
.Optional()
.Rule(title => title.Length > 5).WithMessage("The minimum length is 5")
.Rule(title => title.Length < 10).WithMessage("The maximum length is 10")
)
.Rule(m => !m.Title.Contains("title"))
.WithPath("Title")
.WithCode("TITLE_IN_TITLE")
.Rule(m => m.YearOfFirstAnnouncement < 3000)
.WithMessage("Maximum year value is 3000");
And
helps to maintain the readability by visually separating the rules:
Specification<BookModel> bookSpecificationAnd = s => s
.Member(m => m.Title, m => m
.Optional()
.And()
.Rule(title => title.Length > 5).WithMessage("The minimum length is 5")
.And()
.Rule(title => title.Length < 10).WithMessage("The maximum length is 10")
)
.And()
.Rule(m => !m.Title.Contains("title"))
.WithPath("Title")
.WithCode("TITLE_IN_TITLE")
.And()
.Rule(m => m.YearOfFirstAnnouncement < 3000)
.WithMessage("Maximum year value is 3000");
And
within the fluent API method chain doesn't affect the logic. Both above specifications always produce equal results.
- If the value is entering the scope, presence commands are the first to take action.
- If the value entering the scope is null, scope commands are not executed.
- It doesn't matter how many rules, commands and logic the scope has - it is skipped, and the validation process leaves the scope.
- This is why you don't need to secure your code from
NullReferenceException
in the predicates passed to the Rule (and RuleTemplate) commands. Validot will never pass null to a predicate.
- If the scope doesn't contain any presence command, it acts as it had a single Required command at the beginning.
- Therefore, every specification by default marks the validated input as required (non-null).
- Required command itself doesn't do anything extra comparing to the specification without it, however it gives a possibility to change the error output returned in case the incoming value is null.
- By default, the error output contains the single error message key
Global.Required
.
- By default, the error output contains the single error message key
- Optional command allows the value to be null. In such a case, validation leaves the scope immediately, and no error output is recorded.
- Forbidden command requires the value to be null.
- By default, the error output contains the single error message key
Global.Forbidden
.
- By default, the error output contains the single error message key
- To know how you can modify the error outputs of the presence commands, read their sections: Required, Optional, Forbidden
- The reference loop is a loop in a reference graph of your incoming model.
- In other words; reference loop exists in a model if you traverse through its members and can reach some reference twice at some point.
- On a simple example (imagine a classic linked list, letters represent references):
A->A
, a direct self-reference; type defines a member of the same type and the object has itself assigned there.A->B->C->A
, no direct self-reference, butA
has memberB
, that has memberC
, that has memberA
, so same reference as at the beginning.
public class A
{
public B B { get; set; }
}
public class B
{
public A A { get; set; }
}
Above; simple structure A->B->A
.
- If you're traversing through the object graph and have a reference loop, you can end up in infinite loop and stack overflow exception.
- Reference loops are visible in the Template.
- The root of the loop is marked with message key
Global.ReferenceLoop
.
- The root of the loop is marked with message key
- Reference loop is the only case where the Template doesn't reflect what ultimately lands in the validation result.
- The validation process inside the loop is running normally. However, the lack of caching might slightly affect performance.
Specification<B> specificationB = null;
Specification<A> specificationA = s => s
.Member(m => m.B, specificationB);
specificationB = s => s
.Member(m => m.A, specificationA);
var validator = Validator.Factory.Create(specificationA);
var a = new A()
{
B = new B()
{
A = new A()
{
B = new B()
{
A = null
}
}
}
};
validator.Validate(a).ToString();
// B.A.B.A: Required
- Validot has protection against reference loop.
- When reference loop is detected in the validated object,
ReferenceLoopException
is thrown from theValidate
function, with information like:Type
- what type was at the beginning of the loopPath
- path where the loop startsNestedPath
- path where the loop ends (so where the object (of type described inType
) has same reference as the object under path visible inPath
).ScopeId
- the id of the scope where the loop happens. This is the information from Validot's internals, not useful in the outside world. However please include it when raising an issue, as it will help the dev team.
- When reference loop is detected in the validated object,
Specification<B> specificationB = null;
Specification<A> specificationA = s => s
.Member(m => m.B, specificationB);
specificationB = s => s
.Member(m => m.A, specificationA);
var validator = Validator.Factory.Create(specificationA);
var a = new A()
{
B = new B()
{
A = new A()
{
B = new B()
{
A = null
}
}
}
};
a.B.A.B.A = a.B.A;
try
{
validator.Validate(a);
}
catch(ReferenceLoopException exception)
{
exception.Path; // "B.A"
exception.NestedPath; // "B.A.B.A"
exception.Type; // typeof(A)
}
- Protection against the reference loop is enabled automatically - but only when the risk of such a case is detected.
- The protection uses certain resources (validation needs to track the visited references), but performance drop shouldn't be that much noticeable. Please bear that in mind in case you encounter some extreme corner case.
- You can explicitly enable/disable the protection in the settings.
- Please do know what you're doing; e.g. if disabled, there is no protection from stack overflow exception.
- There is a risk of reference loop and stack overflow if:
- There is a loop in the type graph, and the same types are using the same specification.
- It is true that the loop in the type graph indicates possibility of having the loop in the reference graph, but as long as the same types don't use the same specification - it's totally fine because the validation would never end up in the endless loop.
- Reference loop is reachable at all.
- Validation is based on the specification. If the specification doesn't even step into the members that are in the loop, there is no risk.
- There is a loop in the type graph, and the same types are using the same specification.
- Validator is the object that performs validation process.
- Validator validates the object according to the specification
Specification<T>
.- Validator is a generic class
Validator<T>
whereT
is the type of objects it can validate. - Type
T
comes from specificationSpecification<T>
- Validator is a generic class
- Validator can be created only using can be initialized using the factory.
- Constructor receives two parameters:
- the specification.
- the settings.
- Constructor receives two parameters:
Specification<BookModel> specification = s => s
.Member(m => m.Title, m => m.NotEmpty());
var validator = Validator.Factory.Create(specification);
The code above presents that Validator
can be created with just a specification. The code below presents how to apply settings using a fluent api:
Specification<BookModel> specification = s => s
.Member(m => m.Title, m => m.NotEmpty())
.And()
.Rule(m => m.YearOfPublication > m.YearOfFirstAnnouncement)
.WithCondition(m => m.YearOfPublication.HasValue);
var validator = Validator.Factory.Create(
specification,
s => s.WithPolishTranslation()
);
- On creation, factory executes the specification function and performs an in-depth analysis of all of the commands that it has.
- All of the error messages (along with their translations) are pre-generated and cached.
- They are exposed in the form of a regular validation result (Template property).
- Reference loops are detected.
- If reference loops are possible, reference loop protection is automatically enabled, unless you explicitly disable using WithReferenceLoopProtectionDisabled.
- The reference loop protection slightly decreases the validation performance. It's because the validator needs to track all visited references in order to prevent stack overflow.
- All of the error messages (along with their translations) are pre-generated and cached.
- Validation process always executes the commands in the same order as they appear in the specification.
- Validation process always executes as few commands as possible in order to satisfy the specification.
- Example; if the scope is followed with WithMessage or WithCode, internally the validation executes the rules until the first error is found. This is because it doesn't matter how many of the rules inside fails, they're all going to be overridden by WithMessage or WithCode.
- Example; if the validation process triggered with
failFast
flag, it terminates after detecting the first error.
Specification<BookModel> specification = s => s
.Member(m => m.Title, m => m
.NotEmpty()
.NotWhiteSpace()
.NotEqualTo("blank")
.And()
.Rule(t => !t.StartsWith(" ")).WithMessage("Can't start with whitespace")
)
.WithMessage("Contains errors!");
var validator = Validator.Factory.Create(specification);
var book = new BookModel() { Title = " " };
validator.Validate(book).ToString();
// Title: Contains errors!
Above, the Title
value is checked by NotEmpty
and NotWhiteSpace
rules. NotWhiteSpace
reports an error, therefore there is no need of executing NotEqualTo
and Rule
- as the entire error output is replaced with the message defined in WithMessage.
Validate
is the very function that triggers the full validation process.- It accepts two parameters:
- Model of type
T
- the object to validate. failFast
(default value:false
) - a flag indicating whether the process should terminate immediately after detecting the first error.
- Model of type
- It returns validation result.
- It accepts two parameters:
Specification<BookModel> specification = s => s
.Member(m => m.Title, m => m.NotEmpty())
.And()
.Member(m => m.YearOfFirstAnnouncement, m => m.BetweenOrEqualTo(1000, 3000))
.And()
.Rule(m => m.YearOfPublication >= m.YearOfFirstAnnouncement)
.WithCondition(m => m.YearOfPublication.HasValue)
.WithMessage("Year of publication needs to be after the first announcement");
var validator = Validator.Factory.Create(specification);
var book = new BookModel()
{
Title = "",
YearOfPublication = 600,
YearOfFirstAnnouncement = 666
};
var result = validator.Validate(book);
result.ToString();
// Title: Must not be empty
// YearOfFirstAnnouncement: Must be between 1000 and 3000 (inclusive)
// Year of publication needs to be after the first announcement
var failFastResult = validator.Validate(book, failFast: true);
failFastResult.ToString();
// Title: Must not be empty
In the code above, you can observe that the validation process triggered with failFast
set to true
returns only the first error message from the regular run. It's always going to be the same message - because validation executes the rules in the same order as they appear in the specification.
IsValid
is the highly-optimized version of Validate to check if the model is valid or not.- It's super-fast, but it has its price: no error output and no paths.
- So you don't know what value is wrong and where it is.
- It returns a bool - if
true
, then no error found. Otherwise,false
.
- It's super-fast, but it has its price: no error output and no paths.
Specification<BookModel> specification = s => s
.Member(m => m.Title, m => m.NotEmpty())
.And()
.Member(m => m.YearOfFirstAnnouncement, m => m.BetweenOrEqualTo(1000, 3000))
.And()
.Rule(m => m.YearOfPublication >= m.YearOfFirstAnnouncement)
.WithCondition(m => m.YearOfPublication.HasValue)
.WithMessage("Year of publication needs to be after the first announcement");
var validator = Validator.Factory.Create(specification);
var book1 = new BookModel()
{
Title = "",
YearOfPublication = 600,
YearOfFirstAnnouncement = 666
};
validator.IsValid(book1); // false
var book2 = new BookModel()
{
Title = "test",
YearOfPublication = 1666,
YearOfFirstAnnouncement = 1600
};
validator.IsValid(book2); // true
- In fact,
IsValid
is so fast that it might be a good idea to call it first and then - if model is invalid - triggerValidate
to get all of the details.
if (!validator.IsValid(heavyModel))
{
_logger.Log("Errors found: " + validator.Validate(heavyModel).ToString());
}
- Factory is the way to create the validator instances.
- Factory is exposed through the static member
Factory
of the static classValidator
:
var validator = Validator.Factory.Create(specification);
- Factory contains several methods that allows to create validator instances by receiving:
- Specification and settings builder
- the most popular way
- Validator created using this method can validate objects described by the given specification, using the settings constructed inline with the fluent API.
- Specification holder and settings builder
- Similar to the first option, however the specification is acquired from the specification holder
- (along with the settings, if it's also a settings holder)
- Specification and settings
- Specification and settings builder
Code presenting the usage of specification holder and validator settings holder is placed in their sections.
Below; simple scenario of creating the validator out the specification and settings:
// specifications:
Specification<AuthorModel> authorSpecification = s => s
.Member(m => m.Email, m => m
.Email()
.And()
.EndsWith("@gmail.com")
.WithMessage("Only gmail accounts are accepted")
);
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Title, m => m.NotEmpty().NotWhiteSpace())
.Member(m => m.Authors, m => m.AsCollection(authorSpecification));
// data:
var book = new BookModel()
{
Title = " ",
Authors = new[]
{
new AuthorModel() { Email = "john.doe@gmail.com" },
new AuthorModel() { Email = "john.doe@outlook.com" },
new AuthorModel() { Email = "inv@lidem@il" },
}
};
// validator:
var validator = Validator.Factory.Create(bookSpecification, s => s
.WithTranslation("English", "Texts.Email", "This is not a valid email address!")
);
validator.Validate(book).ToString();
// Title: Must not consist only of whitespace characters
// Authors.#1.Email: Only gmail accounts are accepted
// Authors.#2.Email: This is not a valid email address!
// Authors.#2.Email: Only gmail accounts are accepted
Above you can observe that validator
respects the rules described in the bookSpecification
as well as the settings (notice the custom error message in Authors.#2.Email
).
Below, let's take a look at the continuation of the previous snippet, showing that we can reuse the settings already built for the other validator:
var validator2 = Validator.Factory.Create(bookSpecification, validator.Settings);
validator2.Validate(book).ToString();
// Title: Must not consist only of whitespace characters
// Authors.#1.Email: Only gmail accounts are accepted
// Authors.#2.Email: This is not a valid email address!
// Authors.#2.Email: Only gmail accounts are accepted
- Logically, specification holder is a class that holds specification that factory will fetch and initialize the validator with.
- Technically, specification holder is a class that implements
ISpecificationHolder<T>
generic interface.- This interface exposes single member of type
Specification<T>
.
- This interface exposes single member of type
interface ISpecificationHolder<T>
{
Specification<T> Specification { get; }
}
- Factory has a
Create
method that acceptsISpecificationHolder<T>
instead ofSpecification<T>
.- Specification is taken directly from
Specification
interface member.
- Specification is taken directly from
- Specification holder is a way to wrap the entire specification within a single class.
class BookSpecificationHolder : ISpecificationHolder<BookModel>
{
public BookSpecificationHolder()
{
Specification<string> titleSpecification = s => s
.NotEmpty()
.NotWhiteSpace();
Specification<string> emailSpecification = s => s
.Email()
.EndsWith("@gmail.com").WithMessage("Only gmail accounts are accepted");
Specification<AuthorModel> authorSpecification = s => s
.Member(m => m.Email, emailSpecification);
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Title, titleSpecification)
.Member(m => m.Authors, m => m.AsCollection(authorSpecification));
Specification = bookSpecification;
}
public Specification<BookModel> Specification { get; }
}
Above; example of specification wrapped in the holder. Below; example of usage.
var validator = Validator.Factory.Create(new BookSpecificationHolder());
var book = new BookModel()
{
Title = " ",
Authors = new[]
{
new AuthorModel() { Email = "john.doe@gmail.com" },
new AuthorModel() { Email = "john.doe@outlook.com" },
new AuthorModel() { Email = "inv@lidem@il" },
}
};
validator.Validate(book).ToString();
// Title: Must not consist only of whitespace characters
// Authors.#1.Email: Only gmail accounts are accepted
// Authors.#2.Email: Must be a valid email address
// Authors.#2.Email: Only gmail accounts are accepted
- Logically, a settings holder is a class that holds settings that the factory will fetch and initialize the validator with.
- Technically, settings holder is a class that implements
ISettingsHolder
:
interface ISettingsHolder
{
Func<ValidatorSettings, ValidatorSettings> Settings { get; }
}
- Settings holder needs to expose
Settings
member which - practically - is a fluent API builder. Same as the one used inValidate.Factory.Create
method. - Settings holder is very similar to specification holder, but its purpose is to wrap the settings.
- If the specification holder passed to the Factory implements settings holder as well, the created validator instance will have settings from the holder applied.
public class AuthorSpecificationHolder : ISpecificationHolder<AuthorModel>, ISettingsHolder
{
public AuthorSpecificationHolder()
{
Specification<string> emailSpecification = s => s
.Email()
.EndsWith("@gmail.com");
Specification<AuthorModel> authorSpecification = s => s
.Member(m => m.Email, emailSpecification).WithMessage("Invalid email")
.Member(m => m.Name, m => m.NotEmpty()).WithMessage("Name.EmptyValue");
Specification = authorSpecification;
Settings = s => s
.WithReferenceLoopProtection()
.WithPolishTranslation()
.WithTranslation(new Dictionary<string, IReadOnlyDictionary<string, string>>()
{
["English"] = new Dictionary<string, string>()
{
["Name.EmptyValue"] = "Name must not be empty"
},
["Polish"] = new Dictionary<string, string>()
{
["Invalid email"] = "Nieprawidłowy email",
["Name.EmptyValue"] = "Imię nie może być puste"
}
});
}
public Specification<AuthorModel> Specification { get; }
public Func<ValidatorSettings, ValidatorSettings> Settings { get; }
}
In the above code, specification exposed from the holder internally uses message keys that are resolved in the translations provided in the Settings
builder. The usage would look like:
var validator = Validator.Factory.Create(new AuthorSpecificationHolder());
var author = new AuthorModel()
{
Name = "",
Email = "john.doe@outlook.com",
};
var result = validator.Validate(author);
result.ToString();
// Name: Name must not be empty
// Email: Invalid email
result.ToString("Polish");
// Name: Imię nie może być puste
// Email: Nieprawidłowy email
And the validator's Settings
proves that settings holder has been used:
validator.Settings.Translations.Keys // ["English", "Polish"]
validator.Settings.Translations["English"]["Name.EmptyValue"] // "Name must not be empty"
validator.Settings.Translations["Polish"]["Invalid email"] // "Nieprawidłowy email"
validator.Settings.ReferenceLoopProtection // true
- The factory's
Create
method (Validator.Factory.Create
) that accepts the specification holder, allows to inline modify settings as well.
Let's see this behavior in the below code:
var validator = Validator.Factory.Create(
new AuthorSpecificationHolder(),
s => s
.WithReferenceLoopProtectionDisabled()
.WithTranslation("English", "Invalid email", "The email address is invalid")
);
var author = new AuthorModel()
{
Name = "",
Email = "john.doe@outlook.com",
};
validator.Validate(author).ToString();
// Name: Name must not be empty
// Email: The email address is invalid
validator.Settings.ReferenceLoopProtection; // false
- Factory can create the validator instance using settings taken from another.
- Use the overloaded
Create
method that accepts specification andIValidatorSettings
instance.- You must pass
IValidatorSettings
instance acquired from a validator. Using custom implementations is not supported and will end up with an exception.
- You must pass
Below, validator2
uses settings taken from the previously created validator1
:
Specification<AuthorModel> authorSpecification = s => s
.Member(m => m.Email, m => m.Email().EndsWith("@gmail.com"))
.WithMessage("Invalid email")
.And()
.Member(m => m.Name, m => m.NotEmpty())
.WithMessage("Name.EmptyValue");
var validator1 = Validator.Factory.Create(
authorSpecification,
s => s
.WithTranslation("English", "Invalid email", "The email address is invalid")
.WithTranslation("English", "Name.EmptyValue", "Name must not be empty")
);
var validator2 = Validator.Factory.Create(authorSpecification, validator1.Settings);
var author = new AuthorModel()
{
Name = "",
Email = "john.doe@outlook.com",
};
validator1.Validate(author).ToString()
// Name: Name must not be empty
// Email: The email address is invalid
validator2.Validate(author).ToString()
// Name: Name must not be empty
// Email: The email address is invalid
object.ReferenceEquals(validator1.Settings, validator2.Settings) // true
- Factory has
FetchHolders
method that scans the provided assemblies for specification holders.- You can get all loaded assemblies by calling
AppDomain.CurrentDomain.GetAssemblies()
, or anything else that in your specific case would produce an array ofSystem.Reflection.Assembly
objects. - You can also be more precise and pick only the desired assemblies. For example, by calling
typeof(TypeInTheAssembly).Assembly
. - Specification holder is included in the result collection if it:
- is a class that implements
ISpecificationHolder<T>
interface. - contains a parameterless constructor.
- is a class that implements
- You can get all loaded assemblies by calling
FetchHolders
returns a collection ofHolderInfo
objects, each containing following members:HolderType
- type of the holder, the class that implementsISpecificationHolder<T>
SpecifiedType
- the type that is covered by the specification, it'sT
fromISpecificationHolder<T>
and its memberSpecification<T>
.HoldsSettings
- a flag,true
if the class is also a settings holder (implementsISettingsHolder
interface).CreateValidator
- a method that using reflection creates instance ofHolderType
(with its parametless constructor) and then - the validator out of it.- If you want to use it directly, you need to cast it, as the return type is just top-level
object
.
- If you want to use it directly, you need to cast it, as the return type is just top-level
ValidatorType
- the type of the validator created byCreateValidator
method. It's alwaysIValidator<T>
whereT
isSpecifiedType
.
Let's have a specification holder that holds also the settings:
public class HolderOfIntSpecificationAndSettings : ISpecificationHolder<int>, ISettingsHolder
{
public Specification<int> Specification { get; } = s => s
.GreaterThanOrEqualTo(1).WithMessage("Min value is 1")
.LessThanOrEqualTo(10).WithMessage("Max value is 10");
public Func<ValidatorSettings, ValidatorSettings> Settings { get; } = s => s
.WithTranslation("English", "Min value is 1", "The minimum value is 1")
.WithTranslation("English", "Max value is 10", "The maximum value is 10")
.WithTranslation("BinaryEnglish", "Min value is 1", "The minimum value is 0b0001")
.WithTranslation("BinaryEnglish", "Max value is 10", "The maximum value is 0b1010")
.WithReferenceLoopProtection();
}
It will be detected by FetchHolders
method:
var holder = Validator.Factory.FetchHolders(assemblies).Single(h => h.HolderType == typeof(HolderOfIntSpecificationAndSettings));
var validator = (Validator<int>)holder.CreateValidator();
validator.Validate(11).ToString(translationName: "BinaryEnglish");
// The maximum value is 0b1010
Above, we can observe that the created validator respects the rules and the settings acquired from HolderOfIntSpecificationAndSettings
.
FetchHolders
outputsHolderInfo
in the following order:- Assemblies are analyzed in the order they are provided.
- Or, if called without parameters, it's the order returned by
AppDomain.CurrentDomain.GetAssemblies()
.
- Or, if called without parameters, it's the order returned by
- For each assembly, holders are analyzed in the order they appear in the output of
assembly.GetTypes()
. - For each specification holder, the types are analyzed in the order returned by
type.GetInterfaces()
.
- Assemblies are analyzed in the order they are provided.
- Validot doesn't have any dependencies (apart of the pure .NET Standard 2.0), and therefore - there is no direct support for third-party dependency injection containers.
- However, the factory is able to fetch the holders from the referenced assemblies and provides helpers to create validators out of them.
- For example, if you want your validators to be automatically registered within the DI container, you can implement the following strategy:
- Define specifications for your models in specification holders
- Each in a separate class or everything in the single one - it doesn't matter.
- Call
Validator.Factory.FetchHolders(AppDomain.CurrentDomain.GetAssemblies())
to get the information about the holders and group the results by theSpecifiedType
.- instead of
AppDomain.CurrentDomain.GetAssemblies()
you can pass the array ofSystem.Reflection.Assembly
that the function will scan forISpecificationHolder
implementations. - Theoretically, you could define more than one specification for a single type. Let's assume it's not the case here, but as you will notice, the entire operation is merely a short LINQ call. You can easily adjust it to your needs and/or the used DI container's requirements.
- instead of
- Out of every group, take the
ValidatorType
(this is your registered type) and the result ofCreateValidator
(this is your implementation instance). - It's safe to register validators as singletons.
- Define specifications for your models in specification holders
In ASP.NET Core the services registration by default takes place in the ConfigureServices method. Something like AddValidators
is desirable.
public void ConfigureServices(IServiceCollection services)
{
// it would be great if this line would scan all referenced projects ...
// ... and register validators based on the detected ISpecificationHolder implementations
// services.AddValidators();
}
Instead of AddValidators
you can copy-paste the following lines of code:
public void ConfigureServices(IServiceCollection services)
{
// ... registering other dependencies ...
// Registering Validot's validators from the current domain's loaded assemblies
var holderAssemblies = AppDomain.CurrentDomain.GetAssemblies();
var holders = Validator.Factory.FetchHolders(holderAssemblies)
.GroupBy(h => h.SpecifiedType)
.Select(s => new
{
ValidatorType = s.First().ValidatorType,
ValidatorInstance = s.First().CreateValidator()
});
foreach (var holder in holders)
{
services.AddSingleton(holder.ValidatorType, holder.ValidatorInstance);
}
// ... registering other dependencies ...
}
You can easily specify the exact assemblies for the Validot to scan (by setting up holderAssemblies
collection). Validators are created only from the first ISpecificationHolder
implementation found for each type. To change this logic, adjust the LINQ statement that creates holders
collection.
Of course, you can create the fully-featured AddValidators
extension in the code by saving the following snippet as a new file somewhere in your namespace:
using System;
using System.Linq;
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Validot;
static class AddValidatorsExtensions
{
public static IServiceCollection AddValidators(this IServiceCollection @this, params Assembly[] assemblies)
{
var assembliesToScan = assemblies.Length > 0
? assemblies
: AppDomain.CurrentDomain.GetAssemblies();
var holders = Validator.Factory.FetchHolders(assembliesToScan)
.GroupBy(h => h.SpecifiedType)
.Select(s => new
{
ValidatorType = s.First().ValidatorType,
ValidatorInstance = s.First().CreateValidator()
});
foreach (var holder in holders)
{
@this.AddSingleton(holder.ValidatorType, holder.ValidatorInstance);
}
return @this;
}
}
So it can be used in the ASP.NET Core's Startup.cs
as below:
public void ConfigureServices(IServiceCollection services)
{
// ... registering other dependencies ...
services.AddValidators();
// ... registering other dependencies ...
}
- Settings is the object that holds configuration of the validation process that validator will perform:
- Translations - values for the message keys used in specification.
- Reference loop protection - prevention against stack overflow exception.
- Settings are represented by
IValidatorSettings
interface (namespaceValidot.Settings
). - Validator exposes
Settings
property. - All properties in
IValidatorSettings
are read-only, but under the hood there is an instance ofValidatorSettings
class that has fluent API methods to change the values - You can't create
ValidatorSettings
object directly, but there is no reason to do it. Use the builder pattern exposed by the factory.- Factory initializes the settings object with the default values and exposes it through the fluent API:
var validator = Validator.Factory.Create(specification, settings => settings
.WithReferenceLoopProtection()
);
validator.Settings.ReferenceLoopProtectionEnabled; // true
WithReferenceLoopProtection
enables the protection against the reference loop.- If not explicitly set, the validator turns it on automatically if the reference loop is theoretically possible according to the specification.
WithReferenceLoopProtectionDisabled
disables the protection against the reference loop.- One scenario when this protection is redundant is when you're absolutely sure that the object won't have reference loops, because the model is e.g., deserialized from the string.
- Settings' property
ReferenceLoopProtectionEnabled
holds to final value.
WithTranslation
accepts three parameters:name
- translation namemessageKey
- message keytranslation
- the content for the given message key
settings => settings
.WithTranslation("English", "Global.Error", "Error found")
.WithTranslation("English", "Global.Required", "Value is required")
.WithTranslation("Polish", "Global.Required", "Wartość wymagana");
- Called with keys (
name
ormessageKey
) for the first time,WithTranslation
creates the underlying dictionaries. - Called multiple times with the same keys (
name
andmessageKey
),WithTranslation
overwrites the previous value with the providedtranslation
value. WithTranslation
can also be used to overwrite the existing values (like the default ones or those added before, with anotherWithTranslation
method, in whatever form).- In order to overwrite the default value, you need to check the message key that the rule uses.
- Good to read;
- Translations - how translations work.
- Rules - the list of rules and their message keys.
Specification<AuthorModel> specification = s => s
.Member(m => m.Email, m => m
.NotEmpty()
.Email()
)
.Member(m => m.Name, m => m
.Required().WithMessage("Name is required")
);
var author = new AuthorModel()
{
Email = ""
};
var validator1 = Validator.Factory.Create(specification);
validator1.Validate(author).ToString();
// Email: Must not be empty
// Email: Must be a valid email address
// Name: Name is required
var validator2 = Validator.Factory.Create(specification, settings => settings
.WithTranslation("English", "Name is required", "You must fill out the name")
.WithTranslation("English", "Texts.NotEmpty", "Text value cannot be empty")
);
validator2.Validate(author).ToString();
// Email: Text value cannot be empty
// Email: Must be a valid email address
// Name: You must fill out the name
In the above code, the default value for NotEmpty
(message key Texts.NotEmpty
) has been overridden with the content Text value cannot be empty
WithTranslation
has a version (via extension method) that wraps the base method and accepts:name
- translation nametranslation
- dictionary; its keys are set asmessageKey
and the related values astranslations
.
settings => settings
.WithTranslation("English", new Dictionary<string, string>()
{
["Global.Error"] = "Error found",
["Global.Required"] = "Value is required",
})
.WithTranslation("Polish", new Dictionary<string, string>()
{
["Global.Required"] = "Wartość wymagana",
});
WithTranslation
has a version (via extension method) that wraps the base method and acceptsIReadOnlyDictionary<string, IReadOnlyDictionary<string, string>>
:- the keys is passed as
name
- the value is another dictionary; its keys are set as
messageKey
and the related values astranslations
.
- the keys is passed as
settings => settings
.WithTranslation(new Dictionary<string, IReadOnlyDictionary<string, string>>()
{
["English"] = new Dictionary<string, string>()
{
["Global.Error"] = "Error found",
["Global.Required"] = "Value is required",
},
["Polish"] = new Dictionary<string, string>()
{
["Global.Required"] = "Wartość wymagana",
}
});
WithTranslation
also has extension methods that wrap the base method and add entries for a specific translation:WithEnglishTranslation
- adds English translation, by default always present in the settings.WithPolishTranslation
- adds Polish translation, by default, always present in the settings.
Template
is a byproduct of the analysis that the validator performs during the initialization.- Validator traverses through all of the commands in specification, determines and caches all the possible paths, messages and codes.
Template
is the object of the same type as results (IValidationResult
), so you can check all of the cached data with the same properties, verify the translations, error codes, etc.
Specification<string> specification = s => s
.NotEmpty()
.NotWhiteSpace().WithMessage("White space is not allowed")
.Rule(m => m.Contains('@')).WithMessage("Must contain @ character");
var validator = Validator.Factory.Create(specification);
validator.Template.ToString();
// Required
// Must not be empty
// White space is not allowed
// Must contain @ character
- The first difference between the actual validation result and the
Template
is that theTemplate
doesn't have indexes in the paths.
Specification<BookModel> specification = s => s
.Member(m => m.Authors, m => m
.AsCollection(m1 => m1
.Member(m2 => m2.Name, m2 => m2.NotEmpty())
)
);
var validator = Validator.Factory.Create(specification);
validator.Template.ToString();
// Required
// Authors: Required
// Authors.#: Required
// Authors.#.Name: Required
// Authors.#.Name: Must not be empty
- The second difference between the actual validation result and the
Template
is that the in case of the reference loop,Template
contains only the message set by the keyGlobal.ReferenceLoop
.- The default English translation is
(reference loop)
. - Such error output is placed at the root of the reference loop.
- The default English translation is
Specification<B> specificationB = null;
Specification<A> specificationA = s => s
.Member(m => m.B, specificationB);
specificationB = s => s
.Member(m => m.A, specificationA);
var validator = Validator.Factory.Create(specificationA);
validator.Template.ToString();
// Required
// B: Required
// B.A: (reference loop)
Template
contains all theoretically possible errors, so it would also have the error outputs that in the real world would be exclusive to each other (literally all predicates are ignored).- It also means, that the printing of the
Template
(generated by ToString method) could be quite large.
- It also means, that the printing of the
Specification<AuthorModel> authorSpecification = s => s
.Member(m => m.Email, m => m
.NotWhiteSpace().WithMessage("Email cannot be whitespace")
.Email()
)
.Member(m => m.Name, m => m
.NotEmpty()
.NotWhiteSpace()
.MinLength(2)
);
Specification<BookModel> specification = s => s
.Member(m => m.Title, m => m.NotEmpty()).WithExtraCode("EMPTY_TITLE")
.Member(m => m.YearOfFirstAnnouncement, m => m.BetweenOrEqualTo(1000, 3000))
.Member(m => m.Authors, m => m
.AsCollection(authorSpecification)
.MaxCollectionSize(4).WithMessage("Book shouldn't have more than 4 authors").WithExtraCode("MANY_AUTHORS")
)
.Rule(m => m.YearOfPublication >= m.YearOfFirstAnnouncement)
.WithCondition(m => m.YearOfPublication.HasValue)
.WithMessage("Year of publication needs to be after the first announcement");
var validator = new Validator<BookModel>(specification);
validator.Template.ToString();
// EMPTY_TITLE, MANY_AUTHORS
//
// Required
// Year of publication needs to be after the first announcement
// Title: Required
// Title: Must not be empty
// YearOfFirstAnnouncement: Must be between 1000 and 3000 (inclusive)
// Authors: Required
// Authors: Book shouldn't have more than 4 authors
// Authors.#: Required
// Authors.#.Email: Required
// Authors.#.Email: Email cannot be whitespace
// Authors.#.Email: Must be a valid email address
// Authors.#.Name: Required
// Authors.#.Name: Must not be empty
// Authors.#.Name: Must not consist only of whitespace characters
// Authors.#.Name: Must be at least 2 characters in length
- Validation result is an object of type
IValidationResult
and is produced by the Validate method. - The result is internally linked with the validator that created it.
- This is the reason behind its ability to translate the messages that are registered within the validator.
- This is also the reason you shouldn't store the
IValidationResult
object for too long or pass it around your system.- However, you can retrieve the data using its properties (listed below, here in this section). They are safe to operate on.
AnyErrors
is the flag that returns:true
- if there are errors.false
- no errors and the object is valid according to the specification.
Specification<string> specification = s => s
.NotEmpty();
var validator = Validator.Factory.Create(specification);
var result1 = validator.Validate("test");
result1.AnyErrors; // false
var result2 = validator.Validate("");
result2.AnyErrors; // true
Paths
property is the collection of all paths that contain error output.- It doesn't matter whether it's an error output with only messages, codes, or a mix.
Paths
can be used to check if the value under a certain path is valid or not.Paths
collection doesn't contain duplicates.- To check what messages and/or codes have been saved under a path, you need to use CodeMap and MessageMap.
- The order of the elements in the collection is not guaranteed.
- The empty string means the root model.
Specification<AuthorModel> authorSpecification = s => s
.Member(m => m.Email, m => m.Email().WithCode("EMAIL"))
.Member(m => m.Name, m => m
.NotEmpty()
.MinLength(3)
.NotContains("X").WithMessage("X character is not allowed in name")
);
Specification<BookModel> bookSpecification = s => s
.Member(m => m.Title, m => m.NotWhiteSpace())
.Member(m => m.Authors, m => m
.AsCollection(authorSpecification)
)
.Rule(m => m.IsSelfPublished == false).WithCode("ERROR_SELF_PUBLISHED");
var bookValidator = Validator.Factory.Create(bookSpecification);
var book = new BookModel()
{
Title = "",
Authors = new[]
{
new AuthorModel() { Email = "john.doe@gmail.com", Name = "X" },
new AuthorModel() { Email = "jane.doe@gmail.com", Name = "Jane" },
new AuthorModel() { Email = "inv@lidem@il", Name = "Jane" }
},
IsSelfPublished = true
};
var result = bookValidator.Validate(book);
result.Paths; // [ "", "Title", "Authors.#0.Name", "Authors.#2.Email" ]
In the above example, all paths with errors are listed in Paths
collection. Including Email
and root that contain a single error code. Also, Authors.#0.Name
path has two errors (from MinLength
and NotContains
commands), but it's present only once.
Codes
property is the collection of all the codes in the error output.- The path doesn't matter. All codes from all the error outputs are listed.
Codes
collection can be used to check if some code has been recorded for the validated model.- To check where exactly, you need to use CodeMap.
Codes
collection doesn't contain duplicates.- The order of the elements in the collection is not guaranteed.
Specification<PublisherModel> specification = s => s
.Member(m => m.Name, m => m
.NotEmpty().WithCode("EMPTY_FIELD").WithExtraCode("NAME_ERROR")
.MinLength(3).WithCode("SHORT_FIELD").WithExtraCode("NAME_ERROR")
)
.Member(m => m.CompanyId, m => m
.NotEmpty().WithCode("EMPTY_FIELD").WithExtraCode("COMPANYID_ERROR")
.NotContains("ID").WithCode("ID_IN_CONTENT")
)
.Rule(m => m.Name != m.CompanyId).WithCode("SAME_VALUES");
var validator = Validator.Factory.Create(specification);
var publisher = new PublisherModel()
{
Name = "",
CompanyId = ""
};
var result = validator.Validate(publisher);
result.Codes; // [ "EMPTY_FIELD", "NAME_ERROR", "SHORT_FIELD", "COMPANYID_ERROR", "SAME_VALUES" ]
In the above code, EMPTY_FIELD
and NAME_ERROR
are not duplicated in Codes
, despite the fact that several different rules save them in the error output.
CodeMap
is a dictionary that links error codes with their paths.CodeMap
is property of typeIReadOnlyDictionary<string, IReadOnlyList<string>>
, where:
Specification<PublisherModel> specification = s => s
.Member(m => m.Name, m => m
.NotEmpty().WithCode("EMPTY_FIELD").WithExtraCode("NAME_ERROR")
.MinLength(3).WithCode("SHORT_FIELD").WithExtraCode("NAME_ERROR")
)
.Member(m => m.CompanyId, m => m
.NotEmpty().WithCode("EMPTY_FIELD").WithExtraCode("COMPANYID_ERROR")
.NotContains("company").WithCode("COPANY_IN_CONTENT")
.NotContains("id").WithMessage("Invalid company value")
)
.Rule(m => m.Name is null || m.CompanyId is null).WithCode("NULL_MEMBER");
var validator = Validator.Factory.Create(specification);
var publisher = new PublisherModel()
{
Name = "",
CompanyId = "some_id"
};
var result = validator.Validate(publisher);
result.CodeMap["Name"]; // [ "EMPTY_FIELD", "NAME_ERROR", "SHORT_FIELD", "NAME_ERROR" ]
result.CodeMap[""]; // [ "NULL_MEMBER" ]
- If the path is not present in
CodeMap.Keys
collection, it means no code has been saved for it.- If the path present in Paths collection is missing in
CodeMap.Keys
, it means that the error output for it doesn't contain codes. You should check MessageMap instead.
- If the path present in Paths collection is missing in
result.Paths.Contains("CompanyId"); // true
result.CodeMap.Keys.Contains("CompanyId"); // false
result.MessageMap.Keys.Contains("CompanyId"); // true
MessageMap
is a dictionary that links error messages with their paths.MessageMap
is property of typeIReadOnlyDictionary<string, IReadOnlyList<string>>
, where:- the key is the path.
- the value is the list of error messages saved under the related path.
- the list can contain duplicates.
MessagesMap
always uses the default translation (English
).- If you want to have them translated with a different dictionary, use GetTranslatedMessageMap function.
- Good to read; Translations.
Specification<PublisherModel> specification = s => s
.Member(m => m.Name, m => m
.NotEmpty().WithMessage("The field is empty").WithExtraMessage("Error in Name field")
.MinLength(3).WithMessage("The field is too short").WithExtraMessage("Error in Name field")
)
.Member(m => m.CompanyId, m => m
.NotEmpty().WithMessage("The field is empty").WithExtraMessage("Error in CompanyId field")
.NotContains("company").WithMessage("Company Id cannot contain 'company' word")
.NotContains("id").WithCode("ID_IN_COMPANY")
)
.Rule(m => m.Name is null || m.CompanyId is null)
.WithMessage("All members must be present");
var validator = Validator.Factory.Create(specification);
var publisher = new PublisherModel()
{
Name = "",
CompanyId = "some_id"
};
var result = validator.Validate(publisher);
result.MessageMap["Name"];
// [ "The field is empty", "Error in Name field", "The field is too short", "Error in Name field" ]
result.MessageMap[""];
// [ "All members must be present" ]
- If the path is not present in
MessagesMap.Keys
collection, it means no code has been saved for it.- If the path present in Paths collection is missing in
MessageMap.Keys
, it means that the error output for it doesn't contain codes. You should check CodeMap instead.
- If the path present in Paths collection is missing in
result.Paths.Contains("CompanyId"); // true
result.MessageMap.Keys.Contains("CompanyId"); // false
result.CodeMap.Keys.Contains("CompanyId"); // true
GetTranslatedMessageMap
returns similar result toMessageMap
.- Structure and meaning are the same but the messages are translated.
GetTranslatedMessageMap
accepts single parameter;translationName
:
Specification<AuthorModel> specification = s => s
.Member(m => m.Name, m => m
.NotEmpty()
.MinLength(3).WithMessage("Name is too short")
)
.Member(m => m.Email, m => m
.Email()
);
var validator = Validator.Factory.Create(specification, settings => settings
.WithPolishTranslation()
.WithTranslation("Polish", "Name is too short", "Imię jest zbyt krótkie")
);
var author = new AuthorModel()
{
Name = "",
Email = "inv@lidem@il"
};
var result = validator.Validate(author);
var englishMessageMap = result.GetTranslatedMessageMap("English");
englishMessageMap["Name"]; // [ "Must not be empty", "Name is too short" ]
englishMessageMap["Email"]; // [ "Must be a valid email address" ]
var polishMessageMap = result.GetTranslatedMessageMap("Polish");
polishMessageMap["Name"]; // [ "Musi nie być puste", "Imię jest zbyt krótkie" ]
polishMessageMap["Email"]; // [ "Musi być poprawnym adresem email" ]
- If the given
translationName
is not present in TranslationNames list, exception is thrown. - Good to read;
- Translations - how translation works
- WithTranslation - how to set translation messages
var validator = Validator.Factory.Create(specification, settings => settings
.WithPolishTranslation()
);
var result = validator.Validate(author);
result.GetTranslatedMessageMap("Russian"); // throws KeyNotFoundException
TranslationNames
is a list of all translation names that can be used to translate messages in the result.- The messages can be translated with ToString and GetTranslatedMessageMap functions.
var validator = Validator.Factory.Create(specification);
var result = validator.Validate(model);
result.TranslationNames; // [ "English" ]
- The list is the same as in the Validator that produced the result.
var validator = Validator.Factory.Create(specification, settings => settings
.WithPolishTranslation()
);
var result = validator.Validate(model);
result.TranslationNames; // [ "Polish", "English" ]
ToString
is a helper method that prints the error codes and messages in the following format:- In the first line: all the codes from Codes collection, comma separated.
- If no error codes, the printing starts directly with the messages.
- If there is a line with error codes, it's separated from the messages with the empty line.
- Each message is printed in a separate line, each one preceded with its path.
- In the root path, the message starts from the beginning of the line.
- In the first line: all the codes from Codes collection, comma separated.
- Order of the codes and messages are is guaranteed.
CODE1, CODE2, CODE3
Root message
Path: Message in the path
Path.Nested: Nested message 1
Path.Nested: Nested message 2
Path.Nested: Nested message 3
- Effectively, it's like printing Codes in the first line and then MessageMap.
- The basic version of
ToString
always uses the default translation, which isEnglish
.
Specification<PublisherModel> specification = s => s
.Member(m => m.Name, m => m
.NotEmpty()
.WithMessage("The field is empty")
.WithExtraMessage("Error in Name field")
.WithExtraCode("NAME_EMPTY")
.MinLength(3)
.WithMessage("The field is too short")
.WithExtraCode("NAME_TOO_SHORT")
)
.Member(m => m.CompanyId, m => m
.NotEmpty()
.NotContains("id")
.WithCode("ID_IN_COMPANY")
)
.Rule(m => m.Name is null || m.CompanyId is null)
.WithMessage("All members must be present");
var validator = Validator.Factory.Create(specification);
var publisher = new PublisherModel()
{
Name = "",
CompanyId = "some_id"
};
var result = validator.Validate(publisher);
result.ToString();
// NAME_EMPTY, NAME_TOO_SHORT, ID_IN_COMPANY
// Name: The field is empty
// Name: Error in Name field
// Name: The field is too short
// All members must be present
ToString
also has a version that accepts a single parameter;translationName
. Use to retrieve the same content, but translated using the dictionary of the given name.translationName
needs to be listed in TranslationNames. Otherwise, you can expect an exception.
Specification<PublisherModel> specification = s => s
.Member(m => m.Name, m => m
.NotEmpty()
.MinLength(3)
)
.Member(m => m.CompanyId, m => m
.NotEmpty().WithMessage("CompanyId field is required")
);
var validator = Validator.Factory.Create(specification, settings => settings
.WithPolishTranslation()
.WithTranslation("Polish", "CompanyId field is required", "Pole CompanyId jest wymagane")
);
var publisher = new PublisherModel()
{
Name = "",
CompanyId = ""
};
var result = validator.Validate(publisher);
result.ToString();
// Name: Must not be empty
// Name: Must be at least 3 characters in length
// CompanyId: CompanyId field is required
result.ToString("Polish");
// Name: Musi nie być puste
// Name: Musi być długości minimalnie 3 znaków
// CompanyId: Pole CompanyId jest wymagane
result.ToString("Russian"); // throws exception
- Good to read;
- Translations - how translation works.
- WithTranslation - how to set translation messages.
- In case of a valid result,
ToString
prints simple message:OK
:
Specification<PublisherModel> specification = s => s;
var validator = Validator.Factory.Create(specification);
var model = new PublisherModel();
var result = validator.Validate(model);
result.AnyErrors; // false
result.ToString();
// OK
Fluent api | Message key | Args |
---|---|---|
Rule |
Global.Error |
- |
Required |
Global.Required |
- |
Forbidden |
Global.Forbidden |
- |
reference loop | Global.ReferenceLoop |
- |
- Reference loop error is a special case, it doesn't have the dedicated fluent api command and is related to the existence of reference loop.
- Rules apply to
bool
.
Fluent api | Message key | Args |
---|---|---|
True |
BoolType.True |
- |
False |
BoolType.False |
- |
- Rules apply to
char
. char
can be validated by the below rules and all of the number rules for the unsigned types.
Fluent api | Message key | Args |
---|---|---|
EqualToIgnoreCase |
CharType.True |
value : text |
NotEqualToIgnoreCase |
CharType.False |
value : text |
- Rules apply to any object that implements
IEnumerable<T>
. - There are dedicated generic versions for:
T[]
,IEnumerable<T>
,IList<T>
,IReadOnlyCollection<T>
,IReadOnlyList<T>
,List<T>
.- Dedicated means that you don't need to specify
IEnumerable<T>
andT
explicitly as generic parameters.
- Dedicated means that you don't need to specify
Fluent api | Message key | Args |
---|---|---|
EmptyCollection |
Collections.EmptyCollection |
- |
NotEmptyCollection |
Collections.EmptyCollection |
- |
ExactCollectionSize |
Collections.ExactCollectionSize |
size : number |
MaxCollectionSize |
Collections.MaxCollectionSize |
max : number |
MinCollectionSize |
Collections.MinCollectionSize |
min : number |
CollectionSizeBetween |
Collections.CollectionSizeBetween |
min : number, max : number |
- Rules for all unsigned and signed types:
Fluent api | Message key | Args |
---|---|---|
EqualTo |
Numbers.EqualTo |
value : number |
NotEqualTo |
Numbers.EqualTo |
value : number |
GreaterThan |
Numbers.Greater |
min : number |
GreaterThanOrEqualTo |
Numbers.GreaterThanOrEqualTo |
min : number |
LessThan |
Numbers.LessThan |
max : number |
LessThanOrEqualTo |
Numbers.LessThanOrEqualTo |
max : number |
Between |
Numbers.LessThan |
min : number, max : number |
BetweenOrEqualTo |
Numbers.LessThanOrEqualTo |
min : number, max : number |
NonZero |
Numbers.NonZero |
- |
Positive |
Numbers.Positive |
- |
NonPositive |
Numbers.NonPositive |
- |
- Extra rules just for signed types:
Fluent api | Message key | Args |
---|---|---|
Negative |
Numbers.Negative |
- |
NonNegative |
Numbers.NonNegative |
- |
- Floating-point types
double
andfloat
have a special version of some rules that allows to set the tolerance level- the default value of
tolerance
is0.0000001
. - this is pretty much enforced by the specifics of the binary system, so if you want to avoid the risk, please use
decimal
type.
- the default value of
Fluent api | Message key | Args |
---|---|---|
EqualTo |
Numbers.EqualTo |
value : number, tolerance : number |
NotEqualTo |
Numbers.EqualTo |
value : number, tolerance : number |
NonZero |
Numbers.NonZero |
tolerance : number |
NonNan |
Numbers.NonNan |
- |
- Content rules
- The enum that sets the comparison strategy is the standard StringComparison enum.
- The default value of
stringComparison
isOrdinal
.
Fluent api | Message key | Args |
---|---|---|
EqualTo |
Texts.EqualTo |
value : text, stringComparison : enum |
NotEqualTo |
Texts.NotEqualTo |
value : text, stringComparison : enum |
Contains |
Texts.Contains |
value : text, stringComparison : enum |
NotContains |
Texts.NotContains |
value : text, stringComparison : enum |
StartsWith |
Texts.StartsWith |
value : text, stringComparison : enum |
EndsWith |
Texts.EndsWith |
value : text, stringComparison : enum |
Matches |
Texts.Matches |
pattern : text |
NotEmpty |
Texts.NotEmpty |
- |
NotWhiteSpace |
Texts.NotWhiteSpace |
- |
- Text length rules
- When calculating length,
Environment.NewLine
is count as 1.
- When calculating length,
Fluent api | Message key | Args |
---|---|---|
SingleLine |
Texts.SingleLine |
- |
ExactLength |
Texts.ExactLength |
length : number |
MaxLength |
Texts.MaxLength |
max : number |
MinLength |
Texts.MinLength |
min : number |
LengthBetween |
Texts.LengthBetween |
min : number, max : number |
- Email rules
Email
rule has two modes, set by the enum value of typeValidot.EmailValidationMode
.Email(mode: EmailValidationMode.ComplexRegex)
is set by default (works the same as parameterless.Email()
) and contains the regex-based logic copy-pasted from the Microsoft Docs..Email(mode: EmailValidationMode.DataAnnotationsCompatible)
checks only if the value contains a single@
character in the middle, which is the logic used in the dotnet's System.ComponentModel.DataAnnotations.EmailAddressAttribute.- It's less accurate, but benchmarks show that it's about 6x faster while consuming 32% less memory.
Fluent api | Message key | Args |
---|---|---|
Email |
Texts.Email |
- |
- Rules apply to
DateTime
andDateTimeOffset
. TimeComparison
is the custom enum in Validot and describes the way time should be compared:All
- both date part and time part are compared.JustDate
- only date is compared (the time part is completely skipped)JustTime
- only time is compared (the date part is completely skipped)
Fluent api | Message key | Args |
---|---|---|
Equalto |
Times.Equalto |
value : time, timeComparison : enum |
NotEqualto |
Times.Equalto |
value : time, timeComparison : enum |
After |
Times.After |
min : time, timeComparison : enum |
AfterOrEqualTo |
Times.AfterOrEqualTo |
min : time, timeComparison : enum |
Before |
Times.Before |
max : time, timeComparison : enum |
BeforeOrEqualTo |
Times.BeforeOrEqualTo |
max : time, timeComparison : enum |
Between |
Times.Between |
max : time, timeComparison : enum |
BetweenOrEqualTo |
Times.BetweenOrEqualTo |
min : time, max : time, timeComparison : enum |
- Rules apply to
Guid
.
Fluent api | Message key | Args |
---|---|---|
EqualTo |
GuidType.EqualTo |
value : guid |
NotEqualTo |
GuidType.NotEqualTo |
value : guid |
NotEmpty |
GuidType.NotEmpty |
value : guid |
- Rules apply to
TimeSpan
.- Most of them are same as for numbers, but with different message.
Fluent api | Message key | Args |
---|---|---|
EqualTo |
TimeSpanType.EqualTo |
value : type |
NotEqualTo |
TimeSpanType.EqualTo |
value : type |
GreaterThan |
TimeSpanType.Greater |
min : type |
GreaterThanOrEqualTo |
TimeSpanType.GreaterThanOrEqualTo |
min : type |
LessThan |
TimeSpanType.LessThan |
max : type |
LessThanOrEqualTo |
TimeSpanType.LessThanOrEqualTo |
max : type |
Between |
TimeSpanType.LessThan |
min : type, max : type |
BetweenOrEqualTo |
TimeSpanType.LessThanOrEqualTo |
min : type, max : type |
NonZero |
TimeSpanType.NonZero |
- |
Positive |
TimeSpanType.Positive |
- |
NonPositive |
TimeSpanType.NonPositive |
- |
Negative |
TimeSpanType.Negative |
- |
NonNegative |
TimeSpanType.NonNegative |
- |
- Custom rules should be based on RuleTemplate command, wrapped into an extension method.
- The method needs to extend the
IRuleIn<T>
interface, whereT
is the type of the object to be validated. - The method needs to return
IRuleOut<T>
. - Both
IRuleOut<T>
andIRuleIn<T>
ensure that the custom rule complies with the Validot's fluent api structures.
- The method needs to extend the
- The namespace where the extension method is doesn't matter that much.
- However, all built-in rules live in
Validot
namespace.
- However, all built-in rules live in
public static class MyCustomValidotRules
{
public static IRuleOut<string> HasCharacter(this IRuleIn<string> @this)
{
return @this.RuleTemplate(
m => m.Length > 0,
"Must have at least one character!"
);
}
}
Above, the definition of the custom rule HasCharacters
. Below, the example os usage.
Specification<string> specification = s => s
.HasCharacter();
var validator = Validator.Factory.Create(specification);
validator.Validate("test").AnyErrors; // false
validator.Validate("").ToString();
// Must have at least one character!
- Custom rules can have arguments.
- Please be extra careful with wrapping/boxing external references into the predicate. It might cause the memory leak, especially if the validator does exist as a singleton.
- The pattern is: all method arguments should be available as message arguments under the same names.
public static IRuleOut<string> HasCharacter(
this IRuleIn<string> @this,
char character,
int count = 1)
{
return @this.RuleTemplate(
value => value.Count(c => c == character) == count,
"Must have character '{character}' in the amount of {count}",
Arg.Text(nameof(character), character),
Arg.Number(nameof(count), count)
);
}
Specification<string> specification = s => s
.HasCharacter('t', 2);
var validator = Validator.Factory.Create(specification);
validator.Validate("test").AnyErrors; // false
validator.Validate("").ToString();
// Must have character 't' in the amount of 2
- Instead of a message, you can provide a message key. Technically there is no difference, but it's easier for the user to overwrite the content.
- The pattern for the message key is
Category.MethodName
.- Example;
EqualTo
for texts isTexts.EqualTo
- Example;
GreaterThan
for numbers isNumbers.GreaterThan
- Example;
- Good to read:
- Rules - list of built-in rules, along with their message keys and available arguments.
- Translations - how translations work.
- The pattern for the message key is
public static IRuleOut<string> HasCharacter(
this IRuleIn<string> @this,
char character,
int count = 1)
{
return @this.RuleTemplate(
value => value.Count(c => c == character) == count,
"Text.HasCharacter",
Arg.Text(nameof(character), character),
Arg.Number(nameof(count), count)
);
}
Specification<string> specification = s => s
.HasCharacter('t', 2);
var validator = Validator.Factory.Create(specification, settings => settings
.WithTranslation("English", "Text.HasCharacter", "Must have character '{character}' in the amount of {count}")
.WithTranslation("Polish", "Text.HasCharacter", "Musi zawierać znak '{character}' w ilości {count|culture=pl-PL}")
);
validator.Validate("test").AnyErrors; // false
var result = validator.Validate("");
result.ToString();
// Must have character 't' in the amount of 2
result.ToString(translationName: "Polish");
// Musi zawierać znak 't' w ilości 2
- Error message might contain arguments in its content.
- The placeholder for the argument value is using pattern:
{argumentName}
. - Arguments can be used in WithMessage, WithExtraMessage and RuleTemplate.
- The pattern followed in all the built-in rules is: the argument name is exactly the same as the method's argument.
- The placeholder for the argument value is using pattern:
Specification<decimal> specification = s => s
.Between(min: 0.123M, max: 100.123M)
.WithMessage("The number needs to fit between {min} and {max}");
var validator = Validator.Factory.Create(specification);
validator.Validate(105).ToString();
// The number needs to fit between 0.123 and 100.123
- Arguments can be parametrized:
- Parameters follow format:
parameterName=parameterValue
. - Parameters are separated with
|
(vertical bar, pipe) character from the argument name and from each other. - Single parameter example:
{argumentName|parameterName=parameterValue}
. - Multiple parameters example:
{argumentName|param1=value1|param2=value2|param3=value3}
.
- Parameters follow format:
Specification<decimal> specification = s => s
.Between(min: 0.123M, max: 100.123M)
.WithMessage("The maximum value is {max|format=000.000}")
.WithExtraMessage("The minimum value is {min|format=000.000|culture=pl-PL}");
var validator = Validator.Factory.Create(specification);
validator.Validate(105).ToString();
// The maximum value is 100.123
// The minimum value is 000,123
- Types: all enums
- Created with
Arg.Enum("name", value)
. - Parameters:
format
- number format, the string that goes to ToString method.- if not set, the default value is
G
.
- if not set, the default value is
translation
- if set totrue
, placeholder is transformed into translation argument:{_translation|key=messageKey}
.- the message key is in this format:
Enum.EnumFullTypeName.EnumValueName
. - ultimately, placeholder will be replace with text from the specification.
- if
translation
is present,format
is ignored.
- the message key is in this format:
Placeholder | Argument | Final form |
---|---|---|
{arg} |
StringComparison.Ordinal |
Oridinal |
{arg|format=G} |
StringComparison.Ordinal |
Oridinal |
{arg|format=D} |
StringComparison.Ordinal |
4 |
{arg|format=X} |
StringComparison.Ordinal |
00000004 |
{arg|translation=true} |
StringComparison.Ordinal |
{_translation|key=Enum.System.StringComparison.Ordinal} |
Specification<string> gmailSpecification = s => s
.EndsWith("@gmail.com", stringComparison: StringComparison.OrdinalIgnoreCase)
.WithMessage("Must ends with @gmail.com {stringComparison|translation=true}");
var validator = Validator.Factory.Create(gmailSpecification, settings => settings
.WithTranslation("English", "Enum.System.StringComparison.OrdinalIgnoreCase", "(ignoring case!)")
);
validator.Validate("john.doe@outlook.com").ToString();
// Must ends with @gmail.com (ignoring case!)
In the example above, WithMessage is using {stringComparison|translation=true}
placeholder, which is - under the hood - transformed into translation argument {_translation|key=Enum.System.StringComparison.Ordinal}
and ultimately - replaced with the message registered under the key Enum.System.StringComparison.Ordinal
.
- Good to read:
- translation argument - how to translation argument works.
- translations - how translations work.
- Types:
Guid
- Created with
Arg.GuidValue("name", value)
. - Parameters:
format
- guid format, the string that goes to ToString method.- if not set, the default value is
D
.
- if not set, the default value is
case
- available values:
upper
,lower
. - calls
ToUpper
orToLower
method on the stringified guid value.
- available values:
Placeholder | Argument | Final form |
---|---|---|
{arg} |
c2ce1f3b-17e5-412e-923b-6b4e268f31aa |
c2ce1f3b-17e5-412e-923b-6b4e268f31aa |
{arg|case=upper} |
c2ce1f3b-17e5-412e-923b-6b4e268f31aa |
C2CE1F3B-17E5-412E-923B-6B4E268F31AA |
{arg|format=X} |
c2ce1f3b-17e5-412e-923b-6b4e268f31aa |
{0xc2ce1f3b,0x17e5,0x412e,{0x92,0x3b,0x6b,0x4e,0x26,0x8f,0x31,0xaa}} |
{arg|format=X|case=upper} |
c2ce1f3b-17e5-412e-923b-6b4e268f31aa |
{0XC2CE1F3B,0X17E5,0X412E,{0X92,0X3B,0X6B,0X4E,0X26,0X8F,0X31,0XAA}} |
Specification<Guid> specification = s => s
.NotEqualTo(new Guid("c2ce1f3b-17e5-412e-923b-6b4e268f31aa"))
.WithMessage("Must not be equal to: {value|format=X|case=upper}");
var validator = Validator.Factory.Create(specification);
validator.Validate(new Guid("c2ce1f3b-17e5-412e-923b-6b4e268f31aa")).ToString();
// Must not be equal to: {0XC2CE1F3B,0X17E5,0X412E,{0X92,0X3B,0X6B,0X4E,0X26,0X8F,0X31,0XAA}}
- Types:
int
,uint
,short
,ushort
,long
,ulong
,byte
,sbyte
,decimal
,double
,float
- Created with
Arg.Number("name", value)
. - Parameters:
format
- guid format, the string that goes to the related ToString method.culture
- culture code, the string that goes to the CultureInfo.GetCultureInfo method.- If not set the default culture passed to ToString method is
CultureInfo.InvariantCulture
- If not set the default culture passed to ToString method is
Placeholder | Argument | Final form |
---|---|---|
{arg} |
123.987 |
123.987 |
{arg|format=X} |
123 |
7B |
{arg|format=0.00} |
123.987 |
123.99 |
{arg|culture=pl-PL} |
123.987 |
123,987 |
{arg|format=0.00|culture=pl-PL} |
123.987 |
123,99 |
Specification<decimal> specification = s => s
.EqualTo(666.666M)
.WithMessage("Needs to be equal to {value|format=0.0|culture=pl-PL}");
var validator = Validator.Factory.Create(specification);
validator.Validate(10).ToString();
// Needs to be equal to 666,7
- Types:
string
,char
- Created with
Arg.Text("name", value)
. - Parameters:
case
- available values:
upper
,lower
. - calls
ToUpper
orToLower
method on the stringified guid value. - if not set, the value stays as it is
- available values:
Placeholder | Argument | Final form |
---|---|---|
{arg} |
Bart |
Bart |
{arg|case=upper} |
Bart |
BART |
{arg|case=lower} |
Bart |
bart |
Specification<string> gmailSpecification = s => s
.EndsWith("@gmail.com")
.WithMessage("Must ends with: {value|case=upper}");
var validator = Validator.Factory.Create(gmailSpecification);
validator.Validate("john.doe@outlook.com").ToString();
// Must ends with: @GMAIL.COM
- Types:
DateTime
,DateTimeOffset
,TimeSpan
- Parameters:
format
- guid format, the string that goes to the related ToString method.- The default time format:
HH:mm:ss.FFFFFFF
- The default date format:
yyyy-MM-dd
- The default date and time format:
HH:mm:ss.FFFFFFF yyyy-MM-dd
culture
- culture code, the string that goes to the CultureInfo.GetCultureInfo method.- If not set the default culture passed to ToString method is
CultureInfo.InvariantCulture
- If not set the default culture passed to ToString method is
Placeholder | Argument | Final form |
---|---|---|
{arg} |
new DateTime(2000, 01, 15, 16, 04, 05, 06) |
2000-01-15 16:04:05.006 |
{arg|case=upper} |
new DateTime(2000, 01, 15, 16, 04, 05, 06) |
2000-01-15T16:04:05 |
{arg|case=lower} |
new DateTime(2000, 01, 15, 16, 04, 05, 06) |
20000115 |
Specification<DateTime> specification = s => s
.Before(new DateTime(2000, 1, 2, 3, 4, 5, 6))
.WithMessage("Must not be before: {max|format=yyyy MM dd + HH:mm}");
var validator = Validator.Factory.Create(specification);
validator.Validate(new DateTime(2001, 1, 1, 1, 1, 1, 1)).ToString();
// Must not be before: 2000 01 02 + 03:04
- Translation argument allows to include a phrase from the current translation.
- It's always in this form:
{_translation|key=MessageKey}
Specification<int> specification = s => s
.NotEqualTo(666)
.WithMessage("!!! {_translation|key=TripleSix} !!!");
var validator = Validator.Factory.Create(specification, settings => settings
.WithTranslation("English", "TripleSix", "six six six")
.WithTranslation("Polish", "TripleSix", "sześć sześć sześć")
);
var result = validator.Validate(666);
result.ToString(translationName: "English");
// !!! six six six !!!
result.ToString(translationName: "Polish");
// !!! sześć sześć sześć !!!
- Types:
Type
- Created with
Arg.Type("name", value)
. - Parameters:
format
- available values:
name
,fullName
,toString
. name
- gets the type name, generics are nicely resolved.fullName
- gets the full type name, generics are nicely resolved.toString
- calls ToString().- if not sent, the default
format
value isname
.
- available values:
translation
- if set totrue
, placeholder is transformed into translation argument:{_translation|key=messageKey}
.- the message key is in this format:
Type.FullName
. - ultimately, placeholder will be replaced with text from the specification.
- if
translation
is present,format
is ignored.
- the message key is in this format:
Placeholder | Argument | Final form |
---|---|---|
{arg} |
typeof(int) |
Int32 |
{arg|format=name} |
typeof(int) |
Int32 |
{arg|format=fullName} |
typeof(int) |
System.Int32 |
{arg|format=toString} |
typeof(int) |
System.Int32 |
{arg} |
typeof(int?) |
Nulllable<Int32> |
{arg|format=name} |
typeof(int?) |
Nulllable<Int32> |
{arg|format=fullName} |
typeof(int?) |
System.Nulllable<System.Int32> |
{arg|format=toString} |
typeof(int?) |
System.Nullable'1[System.Int32] |
{arg|translation=true} |
typeof(int?) |
{_translation|key=Type.System.Nullable<System.Int32>} |
- Path argument allows to include the path of the validated value.
- It's always in this form:
{_path}
- It's more difficult to cache such messages (they are less deterministic), so overusing path arguments might slightly decrease the performance.
- It doesn't contain parameters.
Specification<decimal> specification = s => s
.Positive()
.WithPath("Number.Value")
.WithMessage("Number value under {_path} needs to be positive!");
var validator = Validator.Factory.Create(specification);
var result = validator.Validate(-1);
result.ToString();
// Number.Value: Number value under Number.Value needs to be positive!
- In the case of the root path, the value is just an empty string.
- And it might look weird in the final printing.
Specification<decimal> specification = s => s
.Positive()
.WithMessage("Number value under {_path} needs to be positive!");
var validator = Validator.Factory.Create(specification);
var result = validator.Validate(-1);
result.ToString();
// Number value under needs to be positive!
- Name argument allows to include the name of the validated value.
- Name is the last segment of the path.
- Parameters:
format
- available values:
titleCase
.
- available values:
Placeholder | Path | Final form |
---|---|---|
{_name} |
someWeirdName123 |
someWeirdName123 |
{_name|format=titleCase} |
someWeirdName123 |
Some Weird Name 123 |
{_name} |
nested.path.someWeirdName123 |
someWeirdName123 |
{_name|format=titleCase} |
nested.path.someWeirdName123 |
Some Weird Name 123 |
{_name} |
path.This_is_a_Test_of_Network123_in_12_days |
path.This_is_a_Test_of_Network123_in_12_days |
{_name|format=titleCase} |
path.This_is_a_Test_of_Network123_in_12_days |
This Is A Test Of Network 123 In 12 Days |
- It's more difficult (and sometimes it's even impossible) to cache such messages (they are less deterministic), so overusing name arguments might slightly decrease the performance.
Specification<decimal> specification = s => s
.Positive()
.WithPath("Number.Primary.SuperValue")
.WithMessage("The {_name} needs to be positive!");
var validator = Validator.Factory.Create(specification);
var result = validator.Validate(-1);
result.ToString();
// Number.Primary.SuperValue: The SuperValue needs to be positive!
- Use
{_name|format=titleCase}
to get the name title cased.
Specification<decimal> specification = s => s
.Positive()
.WithPath("Number.Primary.SuperDuperValue123")
.WithMessage("The {_name|format=titleCase} needs to be positive!");
var validator = Validator.Factory.Create(specification);
var result = validator.Validate(-1);
result.ToString();
// Number.Primary.SuperDuperValue123: The Super Duper Value 123 needs to be positive!
- Similarly to path argument, in case of the root path, the value is just empty string.
Specification<decimal> specification = s => s
.Positive()
.WithMessage("The {_name} needs to be positive!");
var validator = Validator.Factory.Create(specification);
var result = validator.Validate(-1);
result.ToString();
// The needs to be positive!
- From the purely technical perspective, messages used in the specification are not the error messages, but only the message keys.
- It means that using WithMessage, WithExtraMessage and RuleTemplate, you're setting the message key.
- This also covers all of the default messages like the one if the required value is null.
- The validation result uses the translation process before returning the messages through its methods (e.g. MessageMap or ToString).
- The translation process step by step:
- Get the translation dictionary using its name.
- Look for the message key in the translation dictionary.
- If the message key is present, return the value under the message key.
- If the message key is not present, return the message key.
Specification<string> specification = s => s
.Rule(m => m.Contains("@")).WithMessage("Must contain @ character");
var validator = Validator.Factory.Create(specification);
validator.Validate("").ToString();
// Must contain @ character
In the above code, WithMessage sets "Must contain @ character"
message key for Rule. However, there is no such message key in the standard, default English
translation, so ToString prints the original message key.
- Translation dictionary can be populated using WithTranslation method of the settings object.
Specification<string> specification = s => s
.Rule(m => m.Contains("@")).WithMessage("Must contain @ character");
var validator = Validator.Factory.Create(specification, settings => settings
.WithTranslation("Polish", "Must contain @ character", "Musi zawierać znak: @")
.WithTranslation("English", "Must contain @ character", "Must contain character: @")
);
var result = validator.Validate(model);
result.ToString();
// Must contain character: @
result.ToString("Polish");
// Musi zawierać znak: @
In the above code, WithMessage sets "Must contain @ character"
message key for Rule. But this time, "Must contain @ character"
key exists in both Polish
and English
dictionary (thanks to the WithTranslation method). So the final validation result contains phrases from the dictionaries, not from the WithMessage.
- Good to read:
- WithMessage, WithExtraMessage and RuleTemplate - commands that set message keys.
- WithTranslation - setting entries in the translation dictionary.
- Message arguments - about message arguments (yes, translation phrases can use them!).
- Validot includes some translations out of the box. Technically they are nothing more than extensions that under the hood add phrases to the Validator's settings using WithTranslation method.
- You're more than welcome if you want to contribute new built-in translations to Validot. The process is briefly described in CONTRIBUTING document.
- If you want just to create custom translation for your project only, see Custom translation section.
- The Spanish translation name is just
"Polish"
- It can be included using
WithPolishTranslation()
extension.
Specification<string> specification = s => s
.NotEmpty()
.MaxLength(5);
var validator = Validator.Factory.Create(specification, settings => settings
.WithPolishTranslation()
);
validator.Validate(null).ToString(translationName: "Polish");
// Wymagane
validator.Validate("").ToString(translationName: "Polish");
// Musi nie być puste
validator.Validate("1234567890").ToString(translationName: "Polish");
// Musi być długości maksymalnie 5 znaków
- The Spanish translation name is just
"Spanish"
- It can be included using
WithSpanishTranslation()
extension.
Specification<string> specification = s => s
.NotEmpty()
.MaxLength(5);
var validator = Validator.Factory.Create(specification, settings => settings
.WithSpanishTranslation()
);
validator.Validate(null).ToString(translationName: "Spanish");
// Requerido
validator.Validate("").ToString(translationName: "Spanish");
// No debe estar vacío
validator.Validate("1234567890").ToString(translationName: "Spanish");
// Debe tener como máximo 5 caracteres
- The Russian translation name is just
"Russian"
- It can be included using
WithRussianTranslation()
extension.
Specification<string> specification = s => s
.NotEmpty()
.MaxLength(5);
var validator = Validator.Factory.Create(specification, settings => settings
.WithRussianTranslation()
);
validator.Validate(null).ToString(translationName: "Russian");
// Требуется
validator.Validate("").ToString(translationName: "Russian");
// Не должен быть пуст
validator.Validate("1234567890").ToString(translationName: "Russian");
// Должен быть не больше 5 символов в длину
- The Portuguese translation name is just
"Portuguese"
- It can be included using
WithPortugueseTranslation()
extension.
Specification<string> specification = s => s
.NotEmpty()
.MaxLength(5);
var validator = Validator.Factory.Create(specification, settings => settings
.WithPortugueseTranslation()
);
validator.Validate(null).ToString(translationName: "Portuguese");
// Obrigatório
validator.Validate("").ToString(translationName: "Portuguese");
// Não deve estar vazio
validator.Validate("1234567890").ToString(translationName: "Portuguese");
// Deve ter no máximo 5 caracteres
- The Portuguese translation name is just
"German"
- It can be included using
WithGermanTranslation()
extension.
Specification<string> specification = s => s
.NotEmpty()
.MaxLength(5);
var validator = Validator.Factory.Create(specification, settings => settings
.WithGermanTranslation()
);
validator.Validate(null).ToString(translationName: "German");
// Obrigatório
validator.Validate("").ToString(translationName: "German");
// Não deve estar vazio
validator.Validate("1234567890").ToString(translationName: "German");
// Deve ter no máximo 5 caracteres
- Overriding the default error messages follows the process described in the main Translations section.
- The only missing bit of information is; what are the message key of the default messages?
- And the answer is; there are all listed in Rules section (column
Message key
).
- And the answer is; there are all listed in Rules section (column
- If you want to override some default error message, find it in the Rules section and provide the new value for it using WithTranslation.
Specification<string> specification = s => s
.NotEmpty();
var validator = Validator.Factory.Create(specification, settings => settings
.WithTranslation("English", "Global.Required", "String cannot be null!")
.WithTranslation("English", "Texts.NotEmpty", "String cannot be empty!")
);
validator.Validate(null).ToString();
// String cannot be null!
validator.Validate("").ToString();
// String cannot be empty!
Above code presents how to override the default error messages of NotEmpty
- according to the Rules section, it uses Texts.NotEmpty
message key.
- Translation phrases can use message arguments.
- Similarly to message keys, arguments along with their types are listed in the Rules section of this doc.
Specification<decimal> specification = s => s
.BetweenOrEqualTo(16.66M, 666.666M);
var validator = Validator.Factory.Create(specification, settings => settings
.WithTranslation(
"English",
"Numbers.BetweenOrEqualTo",
"Only numbers between {min|format=000.0000} and {max|format=000.0000} are valid!")
);
validator.Validate(10).ToString();
// Only numbers between 016.6600 and 666.6660 are valid!
BetweenOrEqualTo
uses message key Numbers.BetweenOrEqualTo
and two number arguments: min
and max
.
- Custom translation is nothing more than a translation dictionary that delivers phrases for all the default message keys.
English
translation is the default one, always present in the validator, and it contains all of the phrases.
- To create your own custom translation within your project, you can copy-paste and adjust the following code;
- EnglishTranslation.cs - dictionary with all the message keys.
- EnglishTranslationsExtensions.cs - extension method.
- The pattern is to create extension method to the settings object that wraps WithTranslation calls, delivering phrases for all of the rules.
public static class WithYodaEnglishExtension
{
public static ValidatorSettings WithYodaEnglish(this ValidatorSettings @this)
{
var dictionary = new Dictionary<string, string>()
{
["Global.Required"] = "Exist, it must.",
// more phrases ...
["Numbers.LessThan"] = "Greater than {max}, the number must, be not."
// more phrases ...
};
return @this.WithTranslation("YodaEnglish", dictionary);
}
}
Above, the extension that applies the translation dictionary using WithTranslation. Below, the example of usage:
Specification<int?> specification = s => s
.LessThan(10);
var validator = Validator.Factory.Create(specification, settings => settings
.WithYodaEnglish()
);
validator.Validate(null).ToString("YodaEnglish");
// Exist, it must.
validator.Validate(20).ToString("YodaEnglish");
// Greater than 10, the number must, be not.
- Good to read:
- Built-in translations - translations Validot delivers out of the box.
- Contributing with new translations - how to contribute with a new translation to the Validot project.
- Translations - how translations works.
- Overriding messages - how to override messages.
- Rules - list of message keys and arguments.
- WithTranslation - how to set translation phrase.
- The build system is based on the nuke.build project.
- This section contains examples that uses powershell, but bash scripts are also fully supported.
- Just replace
pwsh build.ps1
withbash build.sh
- Just replace
- If you're keep experiencing compilation errors that your IDE doesn't show (and at the same time
dotnet build
completes OK), consider adding--AllowWarnings
.- By default, the build system requires the code to follow the rules set in editorconfig.
- If you don't provide
--Version
parameter (value needs to follow semver rules), the default version is0.0.0-XHHmmss
, whereX
is the day of the current year,HHmmss
is the timestamp.
- Compile the project with the tests:
pwsh build.ps1
- This is the same as
pwsh build.ps1 --target Compile
- This is the same as
- Create nuget package:
pwsh build.ps1 --target NugetPackage --Version A.B.C --Configuration Release
- Replace
A.B.C
with the semver-compatible version number. - The nuget package version will be
A.B.C
. AssemblyVersion
will beA.0.0.0
.AssemblyFileVersion
will beA.B.C.0
.- The package appears in
artifacts/nuget
directory.
- Clean the project:
pwsh build.ps1 --target Clean
- Deletes all of the
bin
andobj
directories in the solution.
- Reset everything.
pwsh build.ps1 --target Reset
- Restores the original
TargetFramework
in the test projects. - Deletes all diretories created by the build project (
tools
,artifacts
, etc.). - Also, triggers
Clean
target at the end.
- Run tests:
pwsh build.ps1 --target Tests
- The detailed result files (
junit
format) appear inartifacts/tests
directory.
- Run tests on specific framework:
pwsh build.ps1 --target Tests --DotNet netcoreapp2.1
pwsh build.ps1 --target Tests --DotNet net48
- It sets the
TargetFramework
in the test projects' csproj files. - You can use the framework id (
netcoreapp3.1
), as well as the sdk version (3.1.100
)- the highest framework id version available in the sdk will be used.
- Get code coverage report:
pwsh build.ps1 --target CodeCoverageReport
- HTML and JSON reports will appear in
artifacts/coverage_reports
directory. - During this task, the dotnet global tool
dotnet-reportgenerator-globaltool
is installed locally intools
directory. - Reports are tracking history!
- The history data is in
artifacts/coverage_reports/_history
directory.
- The history data is in
- Get code coverage data:
pwsh build.ps1 --target CodeCoverage
- The opencover file will appear in
artifacts/coverage
directory.
- Run all benchmarks:
pwsh build.ps1 --target Benchmarks
- It would take several minutes to complete the execution.
- The results will appear in
artifacts/benchmarks
directory. - By default, the benchmarks are run as
short
jobs.
- Run all benchmarks, better:
pwsh build.ps1 --target Benchmarks --FullBenchmark
- This mode doesn't set job to
short
. - It depends on your machine, but you can assume that it would finish in about 1-2 hours.
- Run benchmarks selectively:
pwsh build.ps1 --target Benchmarks --BenchmarksFilter "X"
X
is the full name of the benchmark method:namespace.typeName.methodName
.- Wildcards are accepted, so
pwsh build.ps1 --target Benchmarks --BenchmarksFilter "*NoErrors*"
would execute all methods insideNoErrorsBenchmark.cs
.
- Wildcards are accepted, so
- Can be combined with
--FullBenchmark
.
- Benchmarks are based on benchmarkdotnet.