I’ve written previously about angulars $http/$resource and about typescript codegeneration, now its time to write a few words about my current project and how we used TypeWriter
and $http
to get a completely typed front-end development.
First a few words about our setup. We are using WebAPI to serve our angularjs-app with JSON. Our controllers are returning Task
of some model or just Task
, something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
[RoutePrefix("api/organizationusers")] public class OrganizationUsersController : ApiController { [HttpGet, Route] public async Task<IEnumerable<UserModel>> GetList() { ... } [HttpGet, Route("{userId:Guid}")] public async Task<EditUserModel> GetEditModel(Guid userId) { ... } [HttpPut, Route("{userId:Guid}")] public async Task<RowVersionModel> Update(Guid userId, UserModel viewModel, byte[] rowVersion) { ... } [HttpDelete, Route("{userId:Guid}")] public async Task Delete(Guid userId) { ... } } |
Our angular-app does not know about either OrganizationUsersController
or the models it’s returning.
Lets set up some goals:
- Generate TypeScript Interfaces matching C#-models.
- Generate TypeScript Classes matching our WebApi-controllers.
- Taking the same parameters
- Returning the correct type
- Constructing a correct url
- Changing/Adding/Removing a property should be automaticaly reflected in our generated code.
Alright, lets get to it!
Install TypeWriter
You can find TypeWriter here: http://frhagn.github.io/Typewriter/
The current version when writing this is 1.0.0beta, but only 0.9.13 is on VisualStudio-gallery. This tutorial uses 1.0.0beta so download the repository from github if its not on VisualStudio-gallery yet.
Generate TypeScript Interfaces matching C#-models
I’ve placed all my models (and only my models) in the App.Models
-namespace.
Add a new .tst-file (Right Click a folder -> Add -> New Item.. -> TypeScript Template file
), call it Models.tst
. Make it look like this:
1 2 3 4 5 6 |
module App { $Classes(App.Models*)[ export interface $Name { $Properties[$name: $Type; ] }] } |
This will take all classes where it’s fullname starts with App.Models
and iterate over them (the [ ...]
syntax). For each class we’ll create a interface, and for each of that class’s properties we’ll create that property on our interface. As simle as that.
Save the file, and poof: your models will apear nested under your Models.tst-file! As simple as that!
You have inheritance in your Models? Alright, lets get that sorted.
Inheritance syntax for TypeScript is like this: interface SomeModel extends SomeBaseModel { ... }
. Lets adjust our export interface..
line to this: export interface $Name $BaseClass[extends $Name] {
. Its simple realy, whats inside [...]
is only written if there is a BaseClass.
Super, goal 1 achieved!
Generate TypeScript Classes matching our WebApi-controllers
This is a bit more complex, but we’ll take it one step at the time.
Add a new .tst-file, called Controllers.tst
:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
${ string ServiceName(Class c) => c.Name.Replace("Controller", "Service"); string Verb(Method m) => m.Attributes.First(a => a.Name.StartsWith("Http")).Name.Remove(0, 4).ToLowerInvariant(); } module App { $Classes(App.Controllers*)[ export class $ServiceName { constructor(private $http: ng.IHttpService) { } $Methods[ public $name = ($Parameters[$name: $Type][, ]) => { return this.$http.$Verb$Type[$IsGeneric[$GenericTypeArguments][<void>]](`$Route`, { $Parameters[$name: $name][, ] }); }] }] } |
This is abit more complex. At the top we’re creating something call extensions
, C#-code that we can use in our template. Our angular-service probably cant be called *Controller, so we’re replacing “Controller” with “Service”. We’re also extracting the http-verb the method is using by looking at its attributes.
Otherwise this template resembles Models.tst
, except for the generic argument to post
: $Type[$IsGeneric[$GenericTypeArguments][<void>]]
. This might read as: given the return $Type
of this $Method
, if it’s Generic (like Task
in our controller) get the GenericTypeArguments, if not: say the type is void.
The output for our Controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
module App { export class OrganizationUsersService { constructor(private $http: ng.IHttpService) { } public getList = () => { return this.$http.get<UserModel[]>(`api/organizationusers/`, { }); } public getEditModel = (userId: string) => { return this.$http.get<EditUserModel>(`api/organizationusers/{userId:Guid}`, { userId: userId }); } public update = (userId: string, viewModel: UserModel, rowVersion: string) => { return this.$http.put<RowVersionModel>(`api/organizationusers/{userId:Guid}`, { userId: userId, viewModel: viewModel, rowVersion: rowVersion }); } public delete = (userId: string) => { return this.$http.delete<void>(`api/organizationusers/{userId:Guid}`, { userId: userId }); } } } |
Close! We’ve achived 2 of our 3 goals for Controllers:
- Taking the same parameters: done!
- Returning the correct type: done!
## Constructing a correct url
This is harder than it might look like. Lets take the Update
-method as an example.
Our generated method will PUT
to api/organizationusers/{userId:Guid}
with a body consisting of all our $Parameters
.
We would like it to PUT
to api/organizationusers/${userId}?rowVersion=${rowVersion}
(using TypeScript string interpolation) with viewModel
as body and our rowVersion as a query-string. Hard, but not impossible. Lets use the power of C#-extensions.
Add these lines above our ServiceName
-method:
1 2 3 4 |
using System.Text.RegularExpressions; string GetRouteValue(Method m, string val) => m.Parameters.Any(p => p.Name == val) ? $"${{{val}}}" : "0"; string RouteParam(string p) => @"\{" + p + @":?\w*\??\}"; string AdjustedRoute(Method method) => Regex.Replace(method.Route(), RouteParam(@"(\w+)"), m => GetRouteValue(method, m.Groups[1].Value)); |
And change $Route
to $AdjustedRoute
in our template. The code will find parameters in the $Route
-string and replace them with TypeScript string interpolation, voila:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
module App { export class OrganizationUsersService { constructor(private $http: ng.IHttpService) { } public getList = () => { this.$http.get<UserModel[]>(`api/organizationusers/`, { }); } public getEditModel = (userId: string) => { this.$http.get<EditUserModel>(`api/organizationusers/${userId}`, { userId: userId }); } public update = (userId: string, viewModel: UserModel, rowVersion: number[]) => { this.$http.put<RowVersionModel>(`api/organizationusers/${userId}`, { userId: userId, viewModel: viewModel, rowVersion: rowVersion }); } public delete = (userId: string) => { this.$http.delete<void>(`api/organizationusers/${userId}`, { userId: userId }); } } } |
Better, but still it lacks in when and what it’s supposed to have as a body. Add two more methods to our extensions:
1 2 3 4 5 6 |
bool RequiresData(Method m) => Verb(m) == "post" || Verb(m) == "put"; string DataParameter(Method m) { var data = m.Parameters.FirstOrDefault(p => !p.Type.IsPrimitive && !p.Attributes.Any(a => a == "FromUri")); return data == null ? "{ }" : data.name; } |
and change our this.$http...
-line to this: return this.$http.$Verb<$Type[$IsGeneric[$GenericTypeArguments[$Prefix$Name]][void]]>(
$AdjustedRoute$RequiresData[, $DataParameter]);
.
This will only take one parameter as body, and the correctly. Our output now looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
module App { export class OrganizationUsersService { constructor(private $http: ng.IHttpService) { } public getList = () => { return this.$http.get<UserModel[]>(`api/organizationusers/`); } public getEditModel = (userId: string) => { return this.$http.get<EditUserModel>(`api/organizationusers/${userId}`); } public update = (userId: string, viewModel: UserModel, rowVersion: number[]) => { return this.$http.put<RowVersionModel>(`api/organizationusers/${userId}`, viewModel); } public delete = (userId: string) => { return this.$http.delete<void>(`api/organizationusers/${userId}`); } } } |
The last piece is missing, our querystring. Add the last two methods to our extensions:
1 2 3 4 5 |
List<Parameter> Params(Method m) { var route = m.Route(); return m.Parameters.Where(p => p.Attributes.Any(a => a == "FromUri") || (p.Type.IsPrimitive && !Regex.IsMatch(route, RouteParam(p)))).ToList(); } bool HasParams(Method m) => Params(m).Any(); |
and adjust our this.$http..
-line: return this.$http.$Verb$Type[$IsGeneric[$GenericTypeArguments][<void>]](
$AdjustedRoute$RequiresData[, $DataParameter]$HasParams[, { params: { $Params[$name: $name][, ] } }]);
If we have any params, parameters that have the [FromUri]
-attribute or IsPrimitive (int, string, enum etc) and does not exist in the route, then use $http
‘s options to create a nice querystring. Output:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
module App { export class OrganizationUsersService { constructor(private $http: ng.IHttpService) { } public getList = () => { return this.$http.get<UserModel[]>(`api/organizationusers/`); } public getEditModel = (userId: string) => { return this.$http.get<EditUserModel>(`api/organizationusers/${userId}`); } public update = (userId: string, viewModel: UserModel, rowVersion: number[]) => { return this.$http.put<RowVersionModel>(`api/organizationusers/${userId}`, viewModel, { params: { rowVersion: rowVersion } }); } public delete = (userId: string) => { return this.$http.delete<void>(`api/organizationusers/${userId}`); } } } |
Alright, high five!
A typed front-end developer experience, reflecting changes in our backend, without us having to write a line. Build errors if removing or changing backend without changing our front-end code. Awesomeness.
- Generate TypeScript Interfaces matching C#-models: done!
- Generate TypeScript Classes matching our WebApi-controllers: done!
- Taking the same parameters: done!
- Returning the correct type: done!
- Constructing a correct url: done!
- Changing/Adding/Removing a property should be automaticaly reflected in our generated code: done!
If you are using TypeScript and MVC/WebAPI you will surely benefit from using TypeWriter to make your Front- and Back-end less seperate, I hope you will give it a try!
Lastly, our ending controller-template in full:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
${ using System.Text.RegularExpressions; string Verb(Method m) => m.Attributes.First(a => a.Name.StartsWith("Http")).Name.Remove(0, 4).ToLowerInvariant(); string ServiceName(Class c) => c.Name.Replace("Controller", "Service"); string GetRouteValue(Method m, string val) => m.Parameters.Any(p => p.Name == val) ? $"${{{val}}}" : "0"; string RouteParam(string p) => @"\{" + p + @":?\w*\??\}"; string AdjustedRoute(Method method) => Regex.Replace(method.Route(), RouteParam(@"(\w+)"), m => GetRouteValue(method, m.Groups[1].Value)); bool RequiresData(Method m) => Verb(m) == "post" || Verb(m) == "put"; string DataParameter(Method m) { var data = m.Parameters.FirstOrDefault(p => !p.Type.IsPrimitive && !p.Attributes.Any(a => a == "FromUri")); return data == null ? "{ }" : data.name; } bool HasParams(Method m) => Params(m).Any(); List<Parameter> Params(Method m) { var route = m.Route(); return m.Parameters.Where(p => p.Attributes.Any(a => a == "FromUri") || (p.Type.IsPrimitive && !Regex.IsMatch(route, RouteParam(p)))).ToList(); } } module App { $Classes(OrganizationUsersController*)[ export class $ServiceName { constructor(private $http: ng.IHttpService) { } $Methods[ public $name = ($Parameters[$name: $Type][, ]) => { return this.$http.$Verb$Type[$IsGeneric[$GenericTypeArguments][<void>]](`$AdjustedRoute`$RequiresData[, $DataParameter]$HasParams[, { params: { $Params[$name: $name][, ] } }]); }] }] } |
One Response
AngularJs: $resource vs $http - Nethouse Blog
[…] Edit: And here is a later blogpost, when you are ready to take this to the next level: Angular & TypeScript: Typed interaction with your WebAPI-backend […]