This is part 5 of how we generate types from our .Net backend to be used in our TypeScript client.
- Why do we even bother?
- Generating TypeScript constants from .Net constants
- Finding the types used in communication between the .Net backend and the TypeScript client
- Generating TypeScript classes from .Net types
- Generating Angular Services from .Net WebApi Controllers
- TypeScript-friendly JSON serialization of F# types
- Testing JSON serialization and deserialization
- Putting all the parts together
Let’s find all the WebApi controllers and create some TypeScript code for them.
We create different service methods in TypeScript depending on whether the controller methods represents a command or a query. So let’s go…
Some basic stuff
First we need some open
s:
module W2w.ServiceWriter
open System
open System.IO
open System.Reflection
open System.Text
open System.Text.RegularExpressions
open System.Threading.Tasks
open Calitime
open Calitime.Api.Clients.Clients.Devices
open Calitime.Api.Clients.Clients.Website
open Calitime.Api.Clients.Filters
open Calitime.Projections.Timeline
open Microsoft.AspNetCore.Mvc
open Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http
open W2w
We also need some helpers. A regex active pattern, and a require
function for Option
to fail when there is no value.
let (|Regex|_|) pattern input =
let m = Regex.Match(input, pattern)
if m.Success then Some(List.tail [ for g in m.Groups -> g.Value ])
else None
module Option =
let require error o =
match o with
| Some x -> x
| None -> failwith error
Handling types
The unpackIfGenericType
function just returns the specified type for non-generic types; otherwise the generic type parameter is returned.
let unpackIfGenericType (sourceType : Type) =
if (sourceType.IsGenericType && sourceType.GetGenericTypeDefinition() = typedefof<List<_>>) // Foo list -> []
|| (sourceType.IsGenericType && sourceType.GetGenericTypeDefinition() = typedefof<System.Collections.Generic.IEnumerable<_>>) // IEnumerable<Foo> -> Foo[]
|| (sourceType.IsGenericType && sourceType.GetGenericTypeDefinition() = typedefof<System.Collections.Generic.IReadOnlyCollection<_>>) // IReadOnlyCollection<Foo> -> Foo[]
|| (sourceType.IsGenericType && sourceType.GetGenericTypeDefinition() = typedefof<System.Collections.Generic.IReadOnlyList<_>>) then // IReadOnlyList<Foo> -> Foo[]
sourceType.GenericTypeArguments.[0]
elif sourceType.IsGenericType && sourceType.GetGenericTypeDefinition() = typedefof<Nullable<_>> then // Nullable<Foo> -> Foo
sourceType.GenericTypeArguments.[0]
else
sourceType
The toLowerCamelCase
function turns the first character to lower, if there is one.
let toLowerCamelCase (value : string) =
match value with
| "" -> ""
| _ ->
let first = $"{value.[0]}".ToLower()
$"{first}{value.Substring(1)}"
The getSpecialTypes
function replaces generic with non-generic types and handles some special cases in our code.
let rec getSpecialTypes (sourceType : Type) =
let sourceType = unpackIfGenericType sourceType
if sourceType.IsGenericType && sourceType.GetGenericTypeDefinition() = typedefof<Maybe<_>> then
[|
typedefof<Maybe<_>>
yield! (sourceType.GenericTypeArguments.[0] |> getSpecialTypes)
|]
// Timeline is a special case because it is translated to ValueInRange<a,b>[]
// that is also the reason why we add the import for ValueInRange to every service, we cannot return the generic type ValueInRange<_,_>
elif sourceType.IsGenericType && sourceType.GetGenericTypeDefinition() = typedefof<Timeline<TypeWriter.Fake,TypeWriter.Fake>> then // Timeline<A,B> -> ValueInRange<A,B>[]
[|
typedefof<ValueInRange<TypeWriter.Fake,TypeWriter.Fake>>
yield! (sourceType.GenericTypeArguments.[0] |> getSpecialTypes)
yield! (sourceType.GenericTypeArguments.[1] |> getSpecialTypes)
|]
else
[| sourceType |]
Import statements
The getImports
function generates the import
statements for a single TypeScript file containing a single service. It gets the relevant assemblies (our assemblies), the types to be ignored, and all the types that are referenced by the service.
let getImports (relevantAssemblies : Assembly[]) (ignoredTypes : Type list) (referencedTypes : Type[]) =
let typesPerAssembly =
referencedTypes
|> Array.collect getSpecialTypes
|> Array.filter (fun t -> relevantAssemblies |> Array.contains t.Assembly)
|> Array.filter (fun t -> not (ignoredTypes |> List.contains t))
|> Array.map (fun t -> TypeWriter.replaceType t |> TypeWriter.ripGenerics, t.Assembly.GetName().Name |> TypeWriter.mapAssembly)
|> Array.distinct
|> Array.groupBy snd
let imports =
typesPerAssembly
|> Array.map (fun (assembly, types) ->
let typeNames = types |> Array.map fst |> String.concat ", "
$"""import {{ {typeNames} }} from "root/generated/{assembly}.types" """)
|> String.concat Environment.NewLine
"""import { Injectable } from "@angular/core";
import { Observable, ReplaySubject, interval, merge } from "rxjs";
import { debounceTime, publishReplay, publish, map, tap, repeatWhen, refCount, shareReplay } from "rxjs/operators";
import { HttpWrapper } from "root/http.wrapper";
import { Comparer } from "root/shared/extensions/object.extensions";
import { Workday } from "root/rootShared/time/workday";
import { UtcDateTime } from "root/rootShared/time/utcdatetime";
import { Maybe, StringAlias } from "root/generated/Infrastructure.types"; ;
""" + imports
Line 3: For all referenced types, we replace their types if they are generic or a special case, we filter out the types that are not from a relevant assembly, we filter out the types that should be ignored. Then we replace the type in the way we have seen in an earlier post and get the mapped assembly (the file the type is written to). Finally, we distinct the result and group it by assembly/file.
Line 11: For every assembly/file, we go through its types and write the corresponding import
statement.
Line 18: We write the complete import
statement consisting of some default imports and the imports for the referenced types.
WebApi Controller methods
The following two types hold the data we need about the web methods and their parameters. We need this data later to generate the service code.
type ParameterInformation =
{
Name : string
ParameterType : Type
FromBody : bool
}
type MethodInformation =
{
Name : string
HttpMethod : HttpMethod
Route : string
Parameters : ParameterInformation[]
ReturnValue : Type
}
We get this data with the getMethods
function:
let getMethods (controller : Type) =
let methods = controller.GetMethods(BindingFlags.DeclaredOnly ||| BindingFlags.Instance ||| BindingFlags.Public)
methods
|> Array.map (fun m ->
{
Name = m.Name
HttpMethod =
m.GetCustomAttributes()
|> Seq.toList
|> List.choose (function
| :? HttpPostAttribute -> Some HttpMethod.Post
| :? HttpPutAttribute -> Some HttpMethod.Put
| :? HttpDeleteAttribute -> Some HttpMethod.Delete
| :? HttpGetAttribute -> Some HttpMethod.Get
| _ -> None)
|> List.tryHead
|> Option.require $"unknown HTTP method on {controller.Name}.{m.Name}"
Route =
let routeAttributes = m.GetCustomAttributes(typeof<RouteAttribute>)
match routeAttributes |> Seq.tryHead with
| Some routeAttribute ->
let r : RouteAttribute = downcast routeAttribute
r.Template
| None -> ""
Parameters =
m.GetParameters()
|> Array.filter (fun p ->
p.GetCustomAttributes(typeof<FromAuthorizationAttribute>) |> Seq.isEmpty)
|> Array.map (fun p ->
{
Name = p.Name
ParameterType = p.ParameterType
FromBody = p.GetCustomAttributes(typeof<FromBodyAttribute>) |> Seq.isEmpty
})
ReturnValue =
// unpack Task<ActionResult<_>>
if m.ReturnType.IsGenericType && m.ReturnType.GetGenericTypeDefinition() = typedefof<Task<_>> then
let taskType = m.ReturnType.GenericTypeArguments.[0]
if taskType.IsGenericType && taskType.GetGenericTypeDefinition() = typedefof<ActionResult<_>> then
taskType.GenericTypeArguments.[0]
else
taskType
else
m.ReturnType
})
Line 2: We get all public instance methods that are defined on the controller directly – not on superclasses.
Line 4: For every method, we get the needed data.
We look for an attribute that defines the HTTP method related to the web method. We fail if none is found. This is another convention of ours: all public methods on controllers have to be web methods with such an attribute.
The route is taken from the Route
attribute if there is one, otherwise it is ""
We filter out parameters with the FromAuthorization
attribute because they do not be included in the generated code. They are passed in by WebApi itself on the backend. For every parameter, we get its name, type and whether the FromBody
attribute is defined.
If the return value is a Task<T>
or a Task<IActionResult<T>>
we unwrap the type.
Now, we have all the data to write the code for a single method. The writeMethod
takes the routePrefix
defined on the controller and the MethodInformation
representing the method we are to write as TypeScript.
let writeMethod routePrefix (m : MethodInformation) =
let parameters =
m.Parameters
|> Array.map (fun p ->
$"{p.Name}: {p.ParameterType |> TypeWriter.replaceType}")
|> String.concat ", "
let parametersWithTrailingComma =
if parameters.Length > 0 then parameters + ", " else ""
let fullRoute = (routePrefix + "/" + m.Route).Replace("{", "${")
let arguments = m.Parameters |> Array.filter (fun p -> not(p.FromBody)) |> Array.map (fun p -> p.Name) |> String.concat ", "
let argumentsWithLeadingComma = if arguments.Length > 0 then $", {arguments}" else ""
let postBody = if arguments.Length > 0 then arguments else "undefined"
let returnType = m.ReturnValue |> TypeWriter.replaceType
let isQuery = m.HttpMethod = HttpMethod.Get || m.Name.StartsWith("Get") || m.Name.StartsWith("Query")
if isQuery then
[|
@$" public {m.Name |> toLowerCamelCase}({parametersWithTrailingComma}listenOn: Observable<any> = interval(9999999)): Observable<{returnType}> {{
let url = `{fullRoute}`;
if(!this.getSourceContainer(url{argumentsWithLeadingComma})) {{
let source = <Observable<any>>this.http
.{m.HttpMethod.ToString() |> toLowerCamelCase}(url{argumentsWithLeadingComma})
.pipe(
repeatWhen(() => merge(this.onChange, listenOn)),
debounceTime(10),
publishReplay(),
refCount(),
map(str => {{
try {{
return JSON.parse(<string>str);
}} catch (e) {{
return str;
}}
}}));
this.sourceContainers.push({{
url: url,
body: {postBody},
source: source
}});
}}
return this.getSourceContainer(url{argumentsWithLeadingComma}).source;
}}
"
@$" public {m.Name |> toLowerCamelCase}WithoutCaching({parametersWithTrailingComma}listenOn: Observable<any> = interval(9999999)): Observable<{returnType}> {{
let url = `{fullRoute}`;
return <Observable<any>>this.http
.{m.HttpMethod.ToString() |> toLowerCamelCase}(url{argumentsWithLeadingComma})
.pipe(
debounceTime(10),
map(str => {{
try {{
return JSON.parse(<string>str);
}} catch (e) {{
return str;
}}
}}));
}}
"
|]
else
// in posts without arguments we need to pass {}
let postArgumentsOrEmpty =
if argumentsWithLeadingComma.Length = 0 && m.HttpMethod = HttpMethod.Post then ", {}" else argumentsWithLeadingComma
[|
@$" public {m.Name |> toLowerCamelCase}({parametersWithTrailingComma}emit = true): Promise<{returnType}> {{
let url = `{fullRoute}`;
return <Promise<any>>this.http
.{m.HttpMethod.ToString() |> toLowerCamelCase}(url{postArgumentsOrEmpty})
.pipe(tap(() => {{
if(emit) {{
this.onChange.next(0);
}}
}}))
.toPromise();
}}
"
|]
Line 2: First, we concatenate the method parameters. Once without a trailing comma, and once with a trailing comma (if there are any).
Line 10: We generate the route, arguments (with and without trailing comma), post body, and the return type.
Line 16: Depending on whether this method represents a query or a command we need to generate a different service method. We use a heuristic to define whether it is a query because we historically have post
methods that are queries (get
methods couldn’t have a body when we started with Timerocket). So we treat methods starting with Get
, or Query
as queries regardless of their HTTP method.
We generate two methods in TypeScript: one with and one without caching to better support our Rx code. We use Observables
as our default and put some debouncing and replaying in there.
Line 64: Commands are a bit easier. The only special case is commands without any arguments. We also trigger an onChange
for parts in the client that are interested in this command.
We do some Rx stuff in there that matches our needs, if you copy this code, make sure to adapt it to your needs.
The getSourceContainer
is used for simple caching. We are not too happy with this because we would prefer a central cache for all services. We probably will refactor this in the future.
WebApi Controller
For every controller, we need to get the route. If there is a Route attribute, we take its value; otherwise, we start with an empty route. For every method, the controller’s route is concatenated with the method’s route (see above).
let getControllerRoute (controller : Type) =
let routeAttributes = controller.GetCustomAttributes(typeof<RouteAttribute>)
let routeAttribute = routeAttributes |> Seq.tryHead
match routeAttribute with
| Some r ->
let routeAttribute' : RouteAttribute = downcast r
routeAttribute'.Template
| None -> ""
The writeService
function takes the relevant assemblies, the ignored types and a map containing web methods that need special handling, and generates the code for a complete service with all its methods.
let writeService (relevantAssemblies : Assembly[]) (ignoredTypes : Type list) (overruledServiceMethods : Map<string, string>) (controller : Type) =
let name =
match controller.Name with
| Regex @"(\w+)Controller" (n::_) -> n
| _ -> failwith $"cannot get controller name from {controller.Name}"
let routePrefix = getControllerRoute controller
let methods = getMethods controller
let methods' =
methods
|> Array.collect (fun m ->
let overrule = overruledServiceMethods |> Map.tryFind (name + "Service." + (m.Name |> toLowerCamelCase))
match overrule with
| Some o -> [| " " + o |]
| None -> writeMethod routePrefix m)
let referencedParameterTypes =
methods
|> Array.collect (fun m -> m.Parameters)
|> Array.map (fun p -> p.ParameterType)
let referencedReturnTypes =
methods
|> Array.map (fun m -> m.ReturnValue)
let referencedTypes =
referencedParameterTypes |> Array.append referencedReturnTypes
let imports = getImports relevantAssemblies ignoredTypes referencedTypes
@$"{imports}
@Injectable({{ providedIn: ""root"" }})
export class {name}Service {{
constructor(private http: HttpWrapper) {{ }}
public onChange = new ReplaySubject(1);
private sourceContainers: any[] = [];
private compare(x, y) {{
if(x == undefined && y == undefined) {{
return true;
}}
let xJson = JSON.stringify(x);
let yJson = JSON.stringify(y);
return Comparer.deepEquals(JSON.parse(xJson), JSON.parse(yJson));
}}
private getSourceContainer(url: string, body: any = undefined) {{
return this.sourceContainers
.firstMatch(c => c.url === url && this.compare(c.body, body));
}}
{methods' |> String.concat Environment.NewLine}
}}
"
Line 2: we get the name of the Controller with the help of a regex.
Line 7: we get the controller’s route
Line 9: we get all the methods and if there is a matching entry in the overruledServiceMethods
map, we use it; otherwise, we generate the code for the method as seen above.
Line 18-30: We build an array containing all the types that are referenced by this service. This array is used to build the import statements of the file.
Line 32: Finally, we write the code for the service class. It includes a simple caching mechanism. We have some ideas to improve this but haven’t found the time or enough pain, yet.
Writing all the services
We need to run the above for every service with the writeServices
function. I think it is straightforward and needs no further explanation.
let writeServices
(controllersAssembly : Assembly)
(relevantAssemblies : Assembly[])
(ignoredTypes : Type list)
(overruledServiceMethods : Map<string, string>)
(outputDirectory : string)
=
let controllers =
controllersAssembly.ExportedTypes
|> Seq.filter (fun t ->
t.BaseType = typeof<WebsiteApiController> ||
t.BaseType = typeof<DevicesApiController>)
for controller in controllers do
let content = writeService relevantAssemblies ignoredTypes overruledServiceMethods controller
let name =
match controller.Name with
| Regex @"(\w+)Controller$" (n::_) -> n
| _ -> failwith $"cannot get controller name from {controller.Name}"
let path = $"{outputDirectory}\\{name |> toLowerCamelCase}.service.ts"
printfn $"writing {path}"
File.WriteAllText(path, content, UTF8Encoding())
Now that we have generated all the shared constants, the shared types , and the services to call the backend, it is time to serialize and deserialize data in a way that C#, F# and TypeScript match together. You can read about this, in the next post. See you there.
This blog post is made possible with the support of Time Rocket, the product this blog post is about. Take a look (German only).
[…] Generating Angular Services from .Net WebApi Controllers […]
[…] Generating Angular Services from .Net WebApi Controllers […]
[…] Type-safety across .Net and TypeScript – Angular Services from WebApi Controllers (Urs Enzler) […]
[…] Generating Angular Services from .Net WebApi Controllers […]
[…] Generating Angular Services from .Net WebApi Controllers […]
[…] our app is divided into a Client (Angular) – Server (ASP.net core) architecture. As explained in this article, we automatically generate Angular services from our ASP controllers, which consume the endpoints […]