Type-safety across .Net and TypeScript – Angular Services from WebApi Controllers

This is part 5 of how we generate types from our .Net backend to be used in our TypeScript client.

  1. Why do we even bother?
  2. Generating TypeScript constants from .Net constants
  3. Finding the types used in communication between the .Net backend and the TypeScript client
  4. Generating TypeScript classes from .Net types
  5. Generating Angular Services from .Net WebApi Controllers
  6. TypeScript-friendly JSON serialization of F# types
  7. Testing JSON serialization and deserialization
  8. 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 opens:

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).

About the author

Urs Enzler

3 comments

By Urs Enzler

Recent Posts