Last active
July 27, 2019 21:31
-
-
Save gistlyn/33873ed2857b2c5a9623629c6210d665 to your computer and use it in GitHub Desktop.
Contacts Validation Example
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Linq; | |
using System.Collections.Generic; | |
using ServiceStack; | |
using ServiceStack.Script; | |
using ServiceStack.Validation; | |
using MyApp.ServiceModel; | |
using MyApp.ServiceModel.Types; | |
namespace MyApp | |
{ | |
public class ConfigureContacts : IConfigureAppHost, IAfterInitAppHost | |
{ | |
public void Configure(IAppHost appHost) | |
{ | |
//Register required plugins if not registered already | |
appHost.Plugins.AddIfNotExists(new ValidationFeature()); | |
appHost.Plugins.AddIfNotExists(new SharpPagesFeature()); | |
appHost.AssertPlugin<SharpPagesFeature>().ScriptMethods.Add(new ContactScripts()); | |
// Register Custom Auto Mapping for converting Contact Data Model to Contact DTO | |
AutoMapping.RegisterConverter((Data.Contact from) => | |
from.ConvertTo<ServiceModel.Types.Contact>(skipConverters:true)); | |
} | |
public void AfterInit(IAppHost appHost) | |
{ | |
View.NavItems.Add(new NavItem { | |
Label = "Contacts", | |
Href = "/contacts/", | |
}); | |
} | |
} | |
// Custom filters for App data sources and re-usable UI snippets in ServiceStack Sharp Pages | |
public class ContactScripts : ScriptMethods | |
{ | |
static Dictionary<string, string> Colors = new Dictionary<string, string> { | |
{"#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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Linq; | |
using System.Drawing; | |
using System.Threading; | |
using System.Globalization; | |
using System.Collections.Concurrent; | |
using ServiceStack; | |
using ServiceStack.Script; | |
using ServiceStack.FluentValidation; | |
using MyApp.ServiceModel; | |
using MyApp.ServiceModel.Types; | |
namespace MyApp.ServiceInterface | |
{ | |
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 | |
[DefaultView("/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 void Any(DeleteContact request) | |
{ | |
if (Contacts.TryGetValue(request.Id, out var contact) && contact.UserAuthId == this.GetUserId()) | |
Contacts.TryRemove(request.Id, out _); | |
} | |
} | |
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"); | |
} | |
} | |
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 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).IsNamedColor); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.ComponentModel; | |
using ServiceStack; | |
namespace MyApp | |
{ | |
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 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 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; } | |
} | |
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, | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{{ 'requires-auth' | partial }} | |
{{ { id } | sendToGateway('GetContact', {catchError:'ex'}) | assignTo: response }} | |
{{#with response.Result}} | |
<h3 class="py-2">Update Contact</h3> | |
<form action="/contacts/{{Id}}" method="post" class="col-lg-4"> | |
<div class="form-group" data-validation-summary="title,name,color,filmGenres,age"></div> | |
<div class="form-group"> | |
<div class="form-check"> | |
{{#each contactTitles}} | |
<div class="custom-control custom-radio custom-control-inline"> | |
<input type="radio" id="title-{{Key}}" name="title" value="{{Key}}" class="custom-control-input"> | |
<label class="custom-control-label" for="title-{{Key}}">{{Value}}</label> | |
</div> | |
{{/each}} | |
</div> | |
</div> | |
<div class="form-group"> | |
<label for="name">Full Name</label> | |
<input class="form-control" id="name" name="name" type="text" placeholder="Name"> | |
<small id="name-help" class="text-muted">Your first and last name</small> | |
</div> | |
<div class="form-group"> | |
<label class="form-label" for="color">Favorite color</label> | |
<select id="color" name="color" class="col-4 form-control"> | |
<option value=""></option> | |
{{#each contactColors}} | |
<option value="{{Key}}">{{Value}}</option> | |
{{/each}} | |
</select> | |
</div> | |
<div class="form-group"> | |
<label class="form-check-label">Favorite Film Genres</label> | |
<div class="form-check"> | |
{{#each contactGenres}} | |
<div class="custom-control custom-checkbox"> | |
<input type="checkbox" id="filmGenres-{{it}}" name="filmGenres" value="{{it}}" class="form-check-input"> | |
<label class="form-check-label" for="filmGenres-{{it}}">{{it}}</label> | |
</div> | |
{{/each}} | |
</div> | |
</div> | |
<div class="form-group"> | |
<input class="form-control col-3" name="age" type="number" min="3" placeholder="Age"> | |
</div> | |
<div class="form-group"> | |
<button class="btn btn-primary" type="submit">Update Contact</button> | |
<a href="/contacts">cancel</a> | |
</div> | |
</form> | |
<script>var CONTACT = {{response.Result | json}};</script> | |
{{#raw}} | |
<script>!window["@servicestack/client"] && document.write(unescape('%3Cscript src="https://unpkg.com/@servicestack/client/dist/servicestack-client.umd.js"%3E%3C/script%3E'));</script> | |
<script> | |
(function(_){ | |
const form = document.querySelector("form"); | |
_.bootstrapForm(form,{ | |
model: CONTACT, | |
success: function () { | |
location.href = '/contacts'; | |
} | |
}); | |
})(window["@servicestack/client"]); | |
</script> | |
{{/raw}} | |
{{else if ex}} | |
<div class="col-6"> | |
<div class="alert alert-warning">{{ex.Message}}</div> | |
<p><a href="/contacts">< back</a></p> | |
</div> | |
{{/with}} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{{ redirectIfNotAuthenticated }} | |
<div style="text-align:right" class="mr-2"> | |
<small class="text-muted"> | |
{{ userSession.DisplayName }} | |
| <a href="/auth/logout?continue=/">Sign Out</a> | |
</small> | |
</div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{{ 'requires-auth' | partial }} | |
<h3 class="py-2">Add new Contact</h3> | |
<form action="/contacts" method="post" class="col-lg-4"> | |
<div class="form-group" data-validation-summary="title,name,color,filmGenres,age,agree"></div> | |
<div class="form-group"> | |
<div class="form-check"> | |
{{#each contactTitles}} | |
<div class="custom-control custom-radio custom-control-inline"> | |
<input type="radio" id="title-{{Key}}" name="title" value="{{Key}}" class="custom-control-input"> | |
<label class="custom-control-label" for="title-{{Key}}">{{Value}}</label> | |
</div> | |
{{/each}} | |
</div> | |
</div> | |
<div class="form-group"> | |
<label for="name">Full Name</label> | |
<input class="form-control" id="name" name="name" type="text" placeholder="Name"> | |
<small id="name-help" class="text-muted">Your first and last name</small> | |
</div> | |
<div class="form-group"> | |
<label class="form-label" for="color">Favorite color</label> | |
<select id="color" name="color" class="col-6 form-control"> | |
<option value=""></option> | |
{{#each contactColors}} | |
<option value="{{Key}}">{{Value}}</option> | |
{{/each}} | |
</select> | |
</div> | |
<div class="form-group"> | |
<label class="form-check-label">Favorite Film Genres</label> | |
<div class="form-check"> | |
{{#each contactGenres}} | |
<div class="custom-control custom-checkbox"> | |
<input type="checkbox" id="filmGenres-{{it}}" name="filmGenres" value="{{it}}" class="form-check-input"> | |
<label class="form-check-label" for="filmGenres-{{it}}">{{it}}</label> | |
</div> | |
{{/each}} | |
</div> | |
</div> | |
<div class="form-group"> | |
<input class="form-control col-4" name="age" type="number" min="13" placeholder="Age"> | |
</div> | |
<div class="form-group"> | |
<div class="form-check"> | |
<input class=" form-check-input" id="agree" name="agree" type="checkbox" value="true"> | |
<label class="form-check-label" for="agree">Agree to terms and conditions</label> | |
</div> | |
</div> | |
<div class="form-group"> | |
<button class="btn btn-primary" type="submit">Add Contact</button> | |
<a class="btn outline-secondary" data-click="reset">reset</a> | |
</div> | |
</form> | |
<table id="results"></table> | |
{{ htmlError }} | |
<script>var CONTACTS = {{ sendToGateway('GetContacts') | map => it.Results | json }};</script> | |
{{#raw appendTo scripts}} | |
<script>!window["@servicestack/client"] && document.write(unescape('%3Cscript src="https://unpkg.com/@servicestack/client/dist/servicestack-client.umd.js"%3E%3C/script%3E'));</script> | |
<script> | |
(function(_){ | |
// Copied from dtos.ts generated by https://docs.servicestack.net/typescript-add-servicestack-reference | |
// @Route("/contacts", "GET") | |
var GetContacts = /** @class */ (function () { | |
function GetContacts(init) { | |
Object.assign(this, init); | |
} | |
GetContacts.prototype.createResponse = function () { return new GetContactsResponse(); }; | |
GetContacts.prototype.getTypeName = function () { return 'GetContacts'; }; | |
return GetContacts; | |
}()); | |
var GetContactsResponse = /** @class */ (function () { | |
function GetContactsResponse(init) { | |
Object.assign(this, init); | |
} | |
return GetContactsResponse; | |
}()); | |
// @Route("/contacts/{Id}", "GET") | |
var GetContact = /** @class */ (function () { | |
function GetContact(init) { | |
Object.assign(this, init); | |
} | |
GetContact.prototype.createResponse = function () { return new GetContactResponse(); }; | |
GetContact.prototype.getTypeName = function () { return 'GetContact'; }; | |
return GetContact; | |
}()); | |
// @Route("/contacts/{Id}", "DELETE") | |
// @Route("/contacts/{Id}/delete", "POST") | |
var DeleteContact = /** @class */ (function () { | |
function DeleteContact(init) { | |
Object.assign(this, init); | |
} | |
DeleteContact.prototype.createResponse = function () { }; | |
DeleteContact.prototype.getTypeName = function () { return 'DeleteContact'; }; | |
return DeleteContact; | |
}()); | |
var client = new _.JsonServiceClient(); | |
var form = document.querySelector("form"); | |
_.bootstrapForm(form,{ | |
success: function (r) { | |
form.reset(); | |
CONTACTS.push(r.result); | |
render(); | |
} | |
}); | |
_.bindHandlers({ | |
reset: function() { | |
document.querySelector('form').reset(); | |
}, | |
deleteContact: function(id) { | |
if (!confirm('Are you sure?')) | |
return; | |
client.delete(new DeleteContact({ id:id })) | |
.then(function() { | |
client.get(new GetContacts()) | |
.then(function(r){ | |
CONTACTS = r.results; | |
render(); | |
}) | |
}); | |
} | |
}); | |
function contactRow(contact) | |
{ | |
return '<tr style="background:' + contact.color + '">' + | |
'<td class="p-2">' + contact.title + ' ' + contact.name + ' (' + contact.age + ')</td>' + | |
'<td class="p-2"><a href="/contacts/' + contact.id + '/edit">edit</a></td>' + | |
'<td class="p-2"><button class="btn btn-sm btn-primary" data-click="deleteContact:' + contact.id + '">delete</button></td>' + | |
'</tr>'; | |
} | |
function render() { | |
var sb = ""; | |
if (CONTACTS.length > 0) { | |
for (let i=0; i<CONTACTS.length; i++) { | |
sb += contactRow(CONTACTS[i]) | |
} | |
} else { | |
sb = "<tr><td>There are no contacts.</td></tr>"; | |
} | |
document.querySelector("#results").innerHTML = '<tbody>' + sb + '</tbody>'; | |
} | |
render(); | |
})(window["@servicestack/client"]); | |
</script> | |
{{/raw}} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment