World Validation

The World Validation App covers a typical Apps examples you would find in most Apps, including Login and Registration Forms to Sign In and Register new Users who are then able to access the same protected Services to maintain their own private contact lists. It's a compact example that tries to cover a lot of use-cases typical in a real-world App, including maintaining a separate Data and DTO Model and using C# idioms like Enum's for defining a finite list of options which are re-used to populate its HTML UI.

The UI for the same App is re-implemented in 10 popular Web Development approaches, each integrated with ServiceStack's validation.

As of this writing there 4 different server HTML generated strategies that use HTML Form Posts to call back-end Services:

View Source on GitHub NetCoreApps/Validation - Live Demo validation.web-app.io

Server Rendered HTML UIs

Client HTML UIs

The Client Examples use Ajax Forms and the TypeScript JsonServiceClient to send TypeScript dtos.ts generated with TypeScript Add ServiceStack Reference:

  • /vuetify - Vue App using Vuetify's Material Design Controls using ServiceClient Requests
  • /client-ts - TypeScript UI using Ajax Forms and ServiceClient Requests
  • /client-jquery - JavaScript UI using jQuery Ajax Requests
  • /client-razor - Client jQuery Ajax Requests rendered by Razor pages
  • /client-vue - Vue UI using TypeScript and ServiceClient Requests
  • /client-react - React UI using TypeScript and ServiceClient Requests

The source code for all different strategies are encapsulated within their folders above except for the Razor examples which need to maintain their shared resources in the /Views folder (representative of friction and restrictions when working with Razor).

Server Implementation

This is the shared backend Server implementation that all UIs are using:

All Auth Configuration is encapsulated within a "no-touch" IConfigureAppHost plugin that's run once on Startup:

// Run before AppHost.Configure()
public class ConfigureAuth : IConfigureAppHost
{
    public void Configure(IAppHost appHost)
    {
        var AppSettings = appHost.AppSettings;
        appHost.Plugins.Add(new AuthFeature(() => new CustomUserSession(),
            new IAuthProvider[] {
                new CredentialsAuthProvider(), //Enable UserName/Password Credentials Auth
            }));

        appHost.Plugins.Add(new RegistrationFeature()); //Enable /register Service

        //override the default registration validation with your own custom implementation
        appHost.RegisterAs<CustomRegistrationValidator, IValidator<Register>>();

        container.Register<ICacheClient>(new MemoryCacheClient()); //Store User Sessions in Memory

        appHost.Register<IAuthRepository>(new InMemoryAuthRepository()); //Store Authenticated Users in Memory

        CreateUser(appHost, "admin@email.com", "Admin User", "p@55wOrd", roles:new[]{ RoleNames.Admin });
    }

    // Add initial Users to the configured Auth Repository
    public void CreateUser(IAppHost appHost, string email, string name, string password, string[] roles)
    {
        var authRepo = appHost.TryResolve<IAuthRepository>();
        var newAdmin = new UserAuth { Email = email, DisplayName = name };
        var user = authRepo.CreateUserAuth(newAdmin, password);
        authRepo.AssignRoles(user, roles);
    }
}

// Type class to store additional metadata in Users Session
public class CustomUserSession : AuthUserSession {}

// Custom Validator to add custom validators to built-in /register Service requiring DisplayName and ConfirmPassword
public class CustomRegistrationValidator : RegistrationValidator
{
    public CustomRegistrationValidator()
    {
        RuleSet(ApplyTo.Post, () =>
        {
            RuleFor(x => x.DisplayName).NotEmpty();
            RuleFor(x => x.ConfirmPassword).NotEmpty();
        });
    }
}

All Services and Validators used in this App. Extension methods are used to DRY reusable code and a Custom Auto Mapping handles conversion between the Contact Data Model and Contact`` DTO:

public class CreateContactValidator : AbstractValidator<CreateContact>
{
    public CreateContactValidator()
    {
        RuleFor(r => r.Title).NotEqual(Title.Unspecified).WithMessage("Please choose a title");
        RuleFor(r => r.Name).NotEmpty();
        RuleFor(r => r.Color).Must(x => x.IsValidColor()).WithMessage("Must be a valid color");
        RuleFor(r => r.FilmGenres).NotEmpty().WithMessage("Please select at least 1 genre");
        RuleFor(r => r.Age).GreaterThan(13).WithMessage("Contacts must be older than 13");
        RuleFor(x => x.Agree).Equal(true).WithMessage("You must agree before submitting");
    }
}

[Authenticate] // Limit to Authenticated Users
[ErrorView(nameof(CreateContact.ErrorView))] // Display ErrorView for HTML requests resulting in an Exception
[DefaultView("/server/contacts")] // Render custom HTML View for HTML Requests
public class ContactServices : Service
{
    private static int Counter = 0;
    internal static readonly ConcurrentDictionary<int, Data.Contact> Contacts = new ConcurrentDictionary<int, Data.Contact>();

    public object Any(GetContacts request)
    {
        var userId = this.GetUserId();
        return new GetContactsResponse
        {
            Results = Contacts.Values
                .Where(x => x.UserAuthId == userId)
                .OrderByDescending(x => x.Id)
                .Map(x => x.ConvertTo<Contact>())
        };
    }

    public object Any(GetContact request) =>
        Contacts.TryGetValue(request.Id, out var contact) && contact.UserAuthId == this.GetUserId()
            ? (object)new GetContactResponse { Result = contact.ConvertTo<Contact>() }
            : HttpError.NotFound($"Contact was not found");

    public object Any(CreateContact request) 
    {
        var newContact = request.ConvertTo<Data.Contact>();
        newContact.Id = Interlocked.Increment(ref Counter);
        newContact.UserAuthId = this.GetUserId();
        newContact.CreatedDate = newContact.ModifiedDate = DateTime.UtcNow;

        var contacts = Contacts.Values.ToList();
        var alreadyExists = contacts.Any(x => x.UserAuthId == newContact.UserAuthId && x.Name == request.Name);
        if (alreadyExists)
            throw new ArgumentException($"You already have a contact named '{request.Name}'", nameof(request.Name));
        
        Contacts[newContact.Id] = newContact;
        return new CreateContactResponse { Result = newContact.ConvertTo<Contact>() };
    }
    
    public object AnyHtml(CreateContact request) // Called for CreateContact API HTML Requests on any HTTP Method
    {
        Any(request);
        return HttpResult.Redirect(request.Continue ?? Request.GetView());
    }

    public void Any(DeleteContact request)
    {
        if (Contacts.TryGetValue(request.Id, out var contact) && contact.UserAuthId == this.GetUserId())
            Contacts.TryRemove(request.Id, out _);
    }

    public object PostHtml(DeleteContact request) // Only called by DeleteContact HTML POST requests 
    {
        Any(request);
        return HttpResult.Redirect(request.Continue ?? Request.GetView()); //added by [DefaultView]
    }
}

public class UpdateContactValidator : AbstractValidator<UpdateContact>
{
    public UpdateContactValidator()
    {
        RuleFor(r => r.Id).GreaterThan(0);
        RuleFor(r => r.Title).NotEqual(Title.Unspecified).WithMessage("Please choose a title");
        RuleFor(r => r.Name).NotEmpty();
        RuleFor(r => r.Color).Must(x => x.IsValidColor()).WithMessage("Must be a valid color");
        RuleFor(r => r.FilmGenres).NotEmpty().WithMessage("Please select at least 1 genre");
        RuleFor(r => r.Age).GreaterThan(13).WithMessage("Contacts must be older than 13");
    }
}

[ErrorView(nameof(UpdateContact.ErrorView))] // Display ErrorView for HTML requests resulting in an Exception
public class UpdateContactServices : Service
{
    public object Any(UpdateContact request)
    {
        if (!ContactServices.Contacts.TryGetValue(request.Id, out var contact) || contact.UserAuthId != this.GetUserId())
            throw HttpError.NotFound("Contact was not found");

        contact.PopulateWith(request);
        contact.ModifiedDate = DateTime.UtcNow;
        
        return request.Continue != null 
            ? (object) HttpResult.Redirect(request.Continue)
            : new UpdateContactResponse();
    }
}

public static class ContactServiceExtensions // DRY reusable logic used in Services and Validators
{
    public static int GetUserId(this Service service) => int.Parse(service.GetSession().UserAuthId);

    public static bool IsValidColor(this string color) => !string.IsNullOrEmpty(color) && 
        (color.FirstCharEquals('#')
            ? int.TryParse(color.Substring(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _)
            : Color.FromName(color).IsKnownColor);
}

// Register Custom Auto Mapping for converting Contact Data Model to Contact DTO
public class ContactsHostConfig : IConfigureAppHost 
{
    public void Configure(IAppHost appHost) =>
        AutoMapping.RegisterConverter((Data.Contact from) => from.ConvertTo<Contact>(skipConverters:true));
}

The dynamic App data used within ServiceStack Sharp Pages and Razor pages are maintained within Custom ContactScripts and RazorHelpers:

// Custom filters for App data sources and re-usable UI snippets in ServiceStack Sharp Pages

using Microsoft.AspNetCore.Mvc.Rendering;
using ServiceModel.Types;
using ServiceStack.Script;

public class ContactScripts : ScriptMethods
{
    internal readonly List<KeyValuePair<string, string>> MenuItems = new()
    {
        KeyValuePair.Create("/", "Home"),
        KeyValuePair.Create("/login-links", "Login Links"),
        KeyValuePair.Create("/register-links", "Register Links"),
        KeyValuePair.Create("/contact-links", "Contacts Links"),
        KeyValuePair.Create("/contact-edit-links", "Edit Contact Links"),
    };

    public List<KeyValuePair<string, string>> menuItems() => MenuItems;

    static Dictionary<string, string> Colors = new()
    {
        {"#ffa4a2", "Red"},
        {"#b2fab4", "Green"},
        {"#9be7ff", "Blue"}
    };

    public Dictionary<string, string> contactColors() => Colors;

    private static List<KeyValuePair<string, string>> Titles => EnumUtils.GetValues<Title>()
        .Where(x => x != Title.Unspecified)
        .ToKeyValuePairs();

    public List<KeyValuePair<string, string>> contactTitles() => Titles;

    private static List<string> FilmGenres => EnumUtils.GetValues<FilmGenres>().Map(x => x.ToDescription());
    public List<string> contactGenres() => FilmGenres;
}

// Razor Helpers for App data sources and re-usable UI snippets in Razor pages
public static class RazorHelpers
{
    internal static readonly ContactScripts Instance = new ContactScripts();
        
    public static Dictionary<string, string> ContactColors(this IHtmlHelper html) => Instance.contactColors();
    public static List<KeyValuePair<string, string>> ContactTitles(this IHtmlHelper html) => Instance.contactTitles();
    public static List<string> ContactGenres(this IHtmlHelper html) => Instance.contactGenres();
    public static List<KeyValuePair<string, string>> MenuItems(this IHtmlHelper html) => Instance.MenuItems;
}

Typed Request/Response Service Contracts including Data and DTO models that utilizes Enum's:

namespace Data // DB Models
{
    using ServiceModel.Types;
    
    public class Contact // Data Model
    {
        public int Id { get; set; }
        public int UserAuthId { get; set; }
        public Title Title { get; set; }
        public string Name { get; set; }
        public string Color { get; set; }
        public FilmGenres[] FilmGenres { get; set; }
        public int Age { get; set; }
        public DateTime CreatedDate { get; set; }
        public DateTime ModifiedDate { get; set; }
    }
}

namespace ServiceModel // Request/Response DTOs
{
    using Types;
    
    [Route("/contacts", "GET")]
    public class GetContacts : IReturn<GetContactsResponse> {}
    public class GetContactsResponse 
    {
        public List<Contact> Results { get; set; }
        public ResponseStatus ResponseStatus { get; set; }
    }

    [Route("/contacts/{Id}", "GET")]
    public class GetContact : IReturn<GetContactResponse >
    {
        public int Id { get; set; }
    }
    public class GetContactResponse 
    {
        public Contact Result { get; set; }
        public ResponseStatus ResponseStatus { get; set; }
    }

    [Route("/contacts", "POST")]
    public class CreateContact : IReturn<CreateContactResponse>
    {
        public Title Title { get; set; }
        public string Name { get; set; }
        public string Color { get; set; }
        public FilmGenres[] FilmGenres { get; set; }
        public int Age { get; set; }
        public bool Agree { get; set; }
        
        public string Continue { get; set; }
        public string ErrorView { get; set; }
    }
    public class CreateContactResponse 
    {
        public Contact Result { get; set; }
        public ResponseStatus ResponseStatus { get; set; }
    }

    [Route("/contacts/{Id}", "POST PUT")]
    public class UpdateContact : IReturn<UpdateContactResponse>
    {
        public int Id { get; set; }
        public Title Title { get; set; }
        public string Name { get; set; }
        public string Color { get; set; }
        public FilmGenres[] FilmGenres { get; set; }
        public int Age { get; set; }
        
        public string Continue { get; set; }
        public string ErrorView { get; set; }
    }
    public class UpdateContactResponse 
    {
        public ResponseStatus ResponseStatus { get; set; }
    }

    [Route("/contacts/{Id}", "DELETE")]
    [Route("/contacts/{Id}/delete", "POST")] // more accessible from HTML
    public class DeleteContact : IReturnVoid
    {
        public int Id { get; set; }
        public string Continue { get; set; }
    }

    namespace Types // DTO Types
    {
        public class Contact 
        {
            public int Id { get; set; }
            public int UserAuthId { get; set; }
            public Title Title { get; set; }
            public string Name { get; set; }
            public string Color { get; set; }
            public FilmGenres[] FilmGenres { get; set; }
            public int Age { get; set; }
        }

        public enum Title
        {
            Unspecified=0,
            [Description("Mr.")] Mr,
            [Description("Mrs.")] Mrs,
            [Description("Miss.")] Miss
        }

        public enum FilmGenres
        {
            Action,
            Adventure,
            Comedy,
            Drama,
        }
    }
}

Each UI implements 4 different screens which are linked from:

  • Login Page - Sign In to ServiceStack's built-in Username/Password Credentials Auth Provider
  • Registration Page - Calling ServiceStack's built-in /register Service to register a new User
  • Contacts Page - Contacts Form to Add a new Contact and view list of existing contacts
  • Edit Contact Page - Edit Contact Form

Shared Error Handling Concepts

Despite their respective differences they share the same concepts where all validation errors are populated from the Service's ResponseStatus Error Response. The UI implementations takes care of binding all matching field errors next to their respective controls whilst the validationSummary or errorResponseExcept methods takes a list of field names that they should not display as they'll already be displayed next to their targeted control.

We'll cover just the Login and Contacts Pages since they're sufficiently different, to see what this looks like in practice:

Login Page

The Login Page contains a standard Bootstrap Username/Password form with labels, placeholders and help text, which initially looks like:

What it looks like after submitting an empty form with Server Exception Errors rendered against their respective fields:

Server UIs

All Server Examples submits a HTML Form Post and renders full page responses:

About Server Implementations

Unfortunately Validation in Bootstrap doesn't lend itself to easy server rendering as it requires co-ordination with label, input and error feedback elements so Sharp Pages wraps this in a formInput control from BootstrapScripts to render both Label and Input elements together. For those preferring Razor, these same controls are available as @Html Helpers as seen in Server Razor which ends up having identical behavior and markup, albeit rendered using a different View Engine.

Server TypeScript shows a more fine-grained version where we show how to bind validation errors to your own custom HTML markup. This would normally end up being a lot more tedious to do so we've extended it with our own declarative data-invalid attribute to hold the fields error message which drastically reduces the manual binding effort required. Calling the bootstrap() method will scan the form for populated data-invalid attributes where it's used to render the appropriate error message adjacent to the control and toggle the appropriate error classes.

All TypeScript examples only depends on the dependency-free @servicestack/client which is available as both an npm package and as a stand-alone servicestack-client.umd.js script include.

The Server jQuery version uses the exact same markup as Server TypeScript but requires a dependency on jQuery and uses the $(document).bootstrap() jQuery plugin from ServiceStack's built-in ss-utils.js.

Continue and ErrorView

In order to enable full-page reloads in ServiceStack's built-in Services like its /auth and /register Services we need to submit 2 additional hidden input fields: errorView to tell it which page it should render on failed requests and continue to tell it where to redirect to after successful requests.

Client UIs

In contrast to full page reloads all Client UIs submit Ajax forms and bind their JSON Error Response to the UI for a more fluid and flicker-free UX:

About Client Implementations

Vuetify is a Vue App which uses the popular Vuetify Material Design UI which is in contrast to all other UIs which use Bootstrap. It also uses the JsonServiceClient to send a JSON Authenticate Request whereas all other UIs sends HTML Form x-www-form-urlencoded Key/Value Pairs.

Client TypeScript only needs to render the initial Bootstrap Form Markup as bootstrapForm() takes care of submitting the Ajax Request and binding any validation errors to the form. The data-validation-summary placeholder is used to render any other error summary messages except for the userName or password fields.

Client jQuery uses the exact same markup but uses $('form').bootstrapForm() jQuery plugin to handle the form Ajax request and any error binding.

Client Razor adopts the same jQuery implementation but is rendered using MVC Razor instead of Sharp Pages.

Contacts Page

The Contacts Page is representative of a more complex page that utilizes a variety of different form controls where the same page is also responsible for rendering the list of existing contacts:

Here's an example of what a partially submitted invalid form looks like:

Server UIs

About Server Implementations

Both the Contacts UIs and Contacts Services are protected resources which uses a partial to protect its pages. Normally redirectIfNotAuthenticated wouldn't require a URL, but one is needed here so it knows the right login page it should redirect to.

Sharp Pages

In Sharp Pages our wrist-friendly server controls are back as we start to see more of its features. The arguments of the left of the formInput are for HTML attributes you want rendered on the input control whilst the arguments on the right (or 2nd argument) are to enlist the controls other "high-level features" like values which is used to populate a list of radio and checkboxes or formSelect options. The inline argument tells the control to render multiple controls in-line whilst you can use help to render some help text as an aside.

We also see the introduction of the sendToGateway method used to send the GetContacts Request DTO to call its Service using the Service Gateway, the Response of which is used to render the list of contacts on the Server.

Another difference is that there are multiple <form> elements on this page to handle deleting a contact by submitting an empty form post to /contacts/{‎{Id}‎}/delete.

Sharp Pages doesn't need to specify its own ErrorView or Continue Request params as its the default view used for ContactServices:

[DefaultView("/server/contacts")] // Render custom HTML View for HTML Requests
public class ContactServices : Service { ... }

This is typically all that's needed, as most real-world Apps would rarely have more than 1 HTML View per Service.

Server TypeScript

With Server TypeScript you're starting to see the additional effort required when you need to use your own custom markup to render form controls.

It differs with Sharp Pages in that instead of rendering the list of contacts on the server, it renders the GetContacts Response DTO as JSON which is interpreted in the browser as a native JS Object literal which the render() method uses to render the list of contacts in the browser.

Deleting a contact is also handled differently where it uses the JsonServiceClient to send the DeleteContact Request DTO from the generated dtos.ts. After the request completes it uses GetContacts to fetch an updated list of Contacts which it re-renders.

Server jQuery

Server jQuery adopts the same approach as Server TypeScript but renders it using jQuery and uses custom routes constructed on the client with jQuery's Ajax APIs to call the ContactServices.

Server Razor

Server Razor is very similar to Sharp Pages but implemented using Razor. In many cases the built-in script methods in Sharp Pages have Razor equivalents, either in the base ViewPage<T> class like RedirectIfNotAuthenticated() or as a @Html helper.

Client UIs

About Client Implementations

Vuetify ends up being larger than other implementations as it also handles Edit Contacts functionality which is a separate page in other UIs. It also includes additional functionality like client-side validation enabled in each control using its :rules attribute. One thing that remains consistent is the way to call ServiceStack Services and handle errors by assigning it to this.responseStatus which the reactive errorResponse method uses to bind to each control.

The remaining client implementations show that whilst the server controls require the least code, if you need custom markup it's much easier to render the initial markup once, then use bootstrapForm() to bind any validation errors and handle the ajax form submissions. It's especially valuable when you need to update a form where the same markup can be populated by just assigning the model property as done in the Edit Contact Pages:

const form = document.querySelector("form")!;
bootstrapForm(form,{
    model: CONTACT,
    success: function () {
        location.href = '/client-ts/contacts/';
    }
});

The amount of code can be even further reduced when using an SPA framework that allows easy componentization as seen in the Vue Form Validation and React Form Validation examples.