Laurie Atkinson, Senior Consultant, avoid duplication of field-level validation by dynamically applying server-side validation rules on Angular controls.
Validation attributes are simple to apply to an Angular control within an HTML template, but that validation logic must be duplicated on the server. Instead, you can build an Angular validation service that parses server-generated rules and dynamically applies those rules using Reactive Forms.
Settle on a schema that your API will provide
Your server-side API will need to return validation rules in some format that can be consumed by the Angular application. Here is an example that will be used in this post. It allows for multiple validation rules for each field and different validator types can include different attributes.
{
properties: [
{
fieldName: 'name',
rules: [{
type: 'requiredValidator',
requiredValidator: {
errorMessage: 'Required'
}
},
{
type: 'maxLengthValidator',
maxLengthValidator: {
maxLength: 30,
errorMessage: 'Value should be no more than 30 characters'
}
}]
},
{
fieldName: 'age',
rules: [{
type: 'numericRangeValidator',
numericRangeValidator: {
minValue: 0,
maxValue: 100,
inclusive: true,
errorMessage: 'Value must be between 0 and 100'
}
}]
}
]
}
Create a TypeScript interface to match the API format
I prefer to use an interface for the data coming from the API, but a class could be used as well. For example:
validation.models.ts
import { AbstractControl, ValidatorFn } from '@angular/forms';
export type validationType = 'requiredValidator' |
'numericMinValueValidator' | 'numericMaxValueValidator' |
'maxLengthValidator' | 'numericRangeValidator' |
'dateMinValueValidator' | 'dateMaxValueValidator' | 'patternValidator';
export interface IValidationRules {
properties: Array<{
fieldName: string,
rules: Array<IValidator>}>;
}
export interface IValidator {
type: validationType;
}
export interface IRequiredValidator extends IValidator {
type: 'requiredValidator';
requiredValidator: {
errorMessage: string;
};
}
export interface IMaxLengthValidator extends IValidator {
type: 'maxLengthValidator';
maxLengthValidator: {
maxLength: number,
errorMessage: string
};
}
export interface INumericRangeValidator extends IValidator {
type: 'numericRangeValidator';
numericRangeValidator: {
minValue: number,
maxValue: number,
inclusive: boolean,
errorMessage: string
};
}
export interface IPatternValidator extends IValidator {
type: 'patternValidator';
patternValidator: {
pattern: string,
errorMessage: string
};
}
export interface ICurrentControlValidators {
control: AbstractControl;
validators: Array<ValidatorFn>;
}
Build the API validation service
Of course, the implementation of the API will depend on your server-side technology. I’ll provide an example using ASP.NET WebAPI, which includes an option to add a filter attribute to an action, a controller, or globally. This allows a request to an API to be intercepted and handled in an alternative way.
So, if a method is decorated with a custom [Validate] filter, then custom code could be executed instead of the method’s implementation. In our case, that custom code would be to generate the validation object and return that to the caller. In the following example, the implementation for the PUT inside the UpdateAddress() method would not execute if the Validate filter succeeds.
[HttpPut]
[Validate]
public async Task<IActionResult> UpdateAddress([FromBody] address)
{ // Implementation for PUT operation }
The custom Validate filter must be able to detect that the caller is attempting to get the validation rules and use the datatype of the argument being passed into the API method to obtain the validation rules for each field in that object.
If the client appends getValidators to the query string, that will trigger the execution of this filter.
public class ValidateAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext actionContext)
{
var queryStrings = actionContext.HttpContext.Request.Query.ToDictionary(x => x.Key, x => x.Value);
if (queryStrings.ContainsKey("getValidators"))
{
emitClientValidation(actionContext);
}
. . .
base.OnActionExecuting(actionContext);
}
private void emitClientValidation(ActionExecutingContext actionContext)
{
// Build a JSON object that matches the schema provided at the beginning of this
// article. Do this by looking at the validators on each property of the type
// of object the controller’s method is expecting.
var sb = new StringBuilder();
using (var sw = new StringWriter(sb))
{
var wr = new JsonTextWriter(sw);
wr.WriteStartObject();
wr.WritePropertyName("properties");
wr.WriteStartArray();
// Look at the argument for the called method.
var argument in actionContext.ActionDescriptor.Parameters[0];
// Recursively scan through the type of the object passed in
// and read the model’s validation attributes.
getValidationRules(argument.ParameterType, wr, null);
wr.WriteEndArray();
wr.WriteEndObject();
wr.Close();
// Return OK with the validation object in the request body.
var obj = JsonConvert.DeserializeObject(sb.ToString());
actionContext.Result = new OkObjectResult(obj);
}
}
private void getValidationRules(Type target, JsonTextWriter writer, string parent)
{
// Implementation of this method depends on your model validation library,
// but it will involve Reflection to read the validation properties
}
}
Create Angular methods for each validator type
Each method should contain the TypeScript code to validate the control’s value and return either an error message or null if valid. The following service shows how to implement 5 examples: required, minimum value, numeric range, maximum length, and pattern (i.e. regex). This is a starting point and could be expanded to include additional field validators as well as comparisons with other controls.
control-validators.service.ts
export class ControlValidators {
static requiredValidator = (errorMessage: string) => {
return (control: AbstractControl) => {
const val: string = control.value;
if (val == null || val.length === 0) {
return {
requiredValidator: errorMessage)
};
}
return null;
};
}
static minValueValidator = (min: number, inclusive: boolean, errorMessage: string) => {
return (control: AbstractControl) => {
if (control.value === null) {
return null;
}
if (isNaN(control.value) || Number(control.value) < min ||
(!inclusive && Number(control.value) === min)) {
return {
minValueValidator: errorMessage
};
}
return null;
};
}
static numericRangeValidator = (range: {
minValue: number,
maxValue: number,
inclusive?: boolean,
errorMessage?: string
}) => {
return (control: AbstractControl) => {
if (control.value === null) {
return null;
}
const num = +control.value;
if (isNaN(control.value) ||
!(num <= range.maxValue && num >= range.minValue) ||
(!range.inclusive &&
(num === range.maxValue || num === range.minValue))) {
return {
rangeValueValidator: range.errorMessage
};
}
return null;
};
}
static maxLengthValidator = (maxLength: number, errorMessage: string) => {
return (control: AbstractControl) => {
if (control.value && control.value.length > maxLength) {
return {
maxLengthValidator: errorMessage
};
}
return null;
};
}
static patternValidator = (pattern: string, errorMessage: string) => {
return (control: AbstractControl) => {
if (control.value) {
const regex = new RegExp(`^${pattern}$`);
const value = <string>control.value;
if (!regex.test(value)) {
return {
patternValidator: errorMessage
};
}
}
return null;
};
}
Dynamically add validators to controls using Reactive Forms
Instead of using template-driven forms, use Reactive Forms to dynamically add controls.
To learn more about Reactive Forms, refer to this documentation.
The component containing the form is responsible for using the FormBuilder class along with the FormGroup class and the addControl() method to create the form that includes all the FormControl objects.
Then, the component will use the following service to apply the validation rules to those controls using the JSON returned by the API. This requires that the names of the controls match the field names used in the JSON.
validation.service.ts
import { Injectable } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { DataService } from '../../services/data.service';
import { INumberValidator, . . . } from '../models/validation.models';
import { ControlValidators } from './control-validators.service';
@Injectable()
export class ValidationService {
// FormGroup is one of the building blocks used in Reactive Forms.
// DataService is a parent class from which all our http services inherit.
// The dataService instance passed in should be a child class of DataService
// and should include an implementation of a method that returns the API URL.
applyValidationRules(formGroup: FormGroup, dataService: DataService) {
const endpoint = this.validationEndpoint(dataService);
return new Promise<Array<ICurrentControlValidators>>((resolve) => {
// Call the PUT method (with the appended getValidators query string param)
// and pass in a null form body since the API update will not be executed
// put<T>(url: string, body: any): Promise<T>
dataService.put<IValidationRules>(endpoint, null)
.then(rules => {
resolve(this.addRulesToControls(formGroup, rules));
})
.catch(() => {
resolve(null); // No validation rules
});
});
}
// Append getValidators=true to the request.
// The API must be coded to recognize this parameter and rather than executing
// the action, instead return a list of validation rules for each property.
private validationEndpoint(dataService: DataService) {
// endpoint() should return the API URL for the request
let endpoint = dataService.endpoint();
endpoint += endpoint.indexOf('?') === -1 ? '?' : '&';
endpoint += 'getValidators=true';
return endpoint;
}
// This method is called after successfully receiving the JSON containing the
// rules for this form.
private addRulesToControls(formGroup: FormGroup,
validationRules: IValidationRules) {
const controlValidators = [];
if (validationRules && validationRules.properties) {
// Go through each property returned by the API call
// properties: [{
// fieldName: 'name',
// rules: [{ . . . }, { . . . }]
// }, . . . ]
for (const prop of validationRules.properties) {
// Use the methods in class ControlValidators described above to create
// an array of all validators for a field.
const validatorArray = this.buildFieldValidators(prop.rules);
if (validatorArray.length > 0) {
const validators = this.applyValidatorToControl(formGroup,
prop.fieldName, validatorArray, null);
if (validators) {
controlValidators.push(validators);
}
}
}
}
// return the list of validators, so that the component could append more.
return controlValidators;
}
// Given the list of rules from the JSON, return a list of the associated
// TypeScript methods based on its type found in the ControlValidators class.
private buildFieldValidators(rules: Array<IValidator>): any[] {
const validatorArray: Array<Function> = [];
for (const rule of rules) {
switch (rule.type) {
case 'requiredValidator':
validatorArray.push(ControlValidators.requiredValidator(
(<IRequiredValidator>rule).requiredValidator.errorMessage));
break;
case 'numericRangeValidator':
validatorArray.push(ControlValidators.numericRangeValidator(
(<INumericRangeValidator>rule).numericRangeValidator));
break;
case 'maxLengthValidator':
validatorArray.push(ControlValidators.maxLengthValidator(
(<IMaxLengthValidator>rule).maxLengthValidator.maxLength,
(<IMaxLengthValidator>rule).maxLengthValidator.errorMessage));
break;
case 'patternValidator':
validatorArray.push(ControlValidators.patternValidator(
(<IPatternValidator>rule).patternValidator.pattern,
(<IPatternValidator>rule).patternValidator.errorMessage));
break;
}
}
return validatorArray;
}
private applyValidatorToControl(formGroup: FormGroup, controlName: string,
validatorArray: any[],
currentValidators: Array<ICurrentControlValidators>
): ICurrentControlValidators {
// Find the control within the FormGroup by name.
// This is the reason that the name must match the field name in the JSON.
const control = formGroup && formGroup.controls ?
formGroup.controls[controlName] : null;
if (control) {
let validators = validatorArray;
// First see if any validators have been added to this control already.
if (currentValidators) {
const currentValidator = currentValidators.find(item => {
return item.control === control;
});
// If found, append this one to the list
if (currentValidator) {
validators = currentValidator.validators.concat(validatorArray);
}
}
control.setValidators(validators);
control.updateValueAndValidity(); // Trigger validation logic
return {
control: control,
validators: validators
};
}
return null;
}
}
This post is based on a successful production solution, but for clarity and space I have done quite a bit of “pruning”. I have attempted to include enough code that you have something that is readable and gives you a starting point. I hope it proves useful for your team.
This would be totally great if it wasn't truncated and of course without the font tags as well as the apersands ;) One thing s for sure, this is an issure all projects that incorporate both angular and .net will meet at some point so I am hoping Microsoft or Goggle or even better, both in cooperation (or maybe your company could get paid by MS / Google or both for it??) will implement both...
I have uploaded a demo app that includes a number of features including server-generated validation rules. I also have 3 API services that are running and have some hardcoded JSON so you can see it in action. Please let me know if this is helpful.
https://github.com/laurieatkinson/ng-patterns-demo
Thanks for the feedback Christian. I’ve re-formatted this content to hopefully make it a little easier to re-purpose. If you run into any issues, please let us know. Thanks for your particpation in the development community!