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​
- /server - Sharp Pages using Server Controls
- /server-ts - Server HTML enhanced with TypeScript
- /server-jquery - Server HTML enhanced with jQuery
- /server-razor - ServiceStack.Razor using Razor Helpers
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.