Skip to content

Quick Start

Pavel Vostretsov edited this page May 9, 2023 · 3 revisions

This guide will show you how to set up TypeScript.ContractGenerator in your web application

You can find the code for this Quick Start on GitHub here: AspNetCoreExample.Api

First, let's create new AspNet WebApi project using template:

dotnet new webapi --output AspNetCoreExample.Api --framework net7.0

Then navigate to created project and add package reference to TypeScript.ContractGenerator:

dotnet add package SkbKontur.TypeScript.ContractGenerator

For TypeScript.ContractGenerator to know how to generate TypeScript, you need to create folder TypeScriptConfiguration and two files:

  • TypesProvider that tells generator what types it should generate, let's start with TypesProvider that returns only existing WeatherForecastController:
public class TypesProvider : IRootTypesProvider
{
    public ITypeInfo[] GetRootTypes()
    {
        return new[] {TypeInfo.From<WeatherForecastController>()};
    }
}
  • CustomGenerator that tells generator how types should be translated to TypeScript:
public class CustomGenerator : CustomTypeGenerator
{
    public CustomGenerator()
    {
        var controllerBase = TypeInfo.From<ControllerBase>();
        WithTypeLocationRule(t => controllerBase.IsAssignableFrom(t), t => $"Api/{t.Name.Replace("Controller", "Api")}")
            .WithTypeLocationRule(t => !controllerBase.IsAssignableFrom(t), t => $"DataTypes/{t.Name}")
            .WithTypeBuildingContext(t => controllerBase.IsAssignableFrom(t), (u, t) => new ApiControllerTypeBuildingContext(u, t));
    }
}

Above we set up several rules for our generator:

  • We should put types that extend ControllerBase to folder ./Api, the resulting file for WeatherForecastController will be ./Api/WeatherForecastApi.ts
  • We should put everything else to ./DataTypes folder, for example, WeaterForecast will be put to ./DataTypes/WeatherForecast.ts
  • We should use ApiControllerTypeBuildingContext to generate api from inheritors of ControllerBase

Now we can generate some TypeScript:

  • Install dotnet tool:
dotnet tool install --global SkbKontur.TypeScript.ContractGenerator.Cli
  • Run it (don't forget to build our project beforehand):
dotnet ts-gen -a ./bin/Debug/net7.0/AspNetCoreExample.Api.dll -o output --nullabilityMode NullableReference

In output folder we should get several files with following structure:

├── output
│   ├── Api
│   │   ├── WeatherForecastApi.ts
│   ├── DataTypes
│   │   ├── DateOnly.ts
│   │   ├── DayOfWeek.ts
│   │   ├── WeatherForecast.ts

Let's analyze files in output folder.

  • WeatherForecastApi.ts will contain class WeatherForecastApi with api methods and interface IWeatherForecastApi with same methods. Method WeatherForecastController.Get will translate into:
export class WeatherForecastApi extends ApiBase implements IWeatherForecastApi {
    async get(): Promise<WeatherForecast[]> {
        return this.makeGetRequest(`/WeatherForecast`, {
            
        }, {
            
        });
    }

};

It's worth noting that by default generated file with api expects that output folder contains ./ApiBase/ApiBase.ts file with several methods:

export class ApiBase {
    public async makeGetRequest(url: string, queryParams: Record<string, any>, body: any): Promise<any> { ... }
    public async makePostRequest(url: string, queryParams: Record<string, any>, body: any): Promise<any> { ... }
    public async makePutRequest(url: string, queryParams: Record<string, any>, body: any): Promise<any> { ... }
    public async makeDeleteRequest(url: string, queryParams: Record<string, any>, body: any): Promise<any> { ... }
}

Let's look into DataTypes folder next

  • Here's what generated WeatherForecast.ts looks like:
C# TypeScript
public class WeatherForecast
{
    public DateOnly Date { get; set; }
    public int TemperatureC { get; set; }
    public int TemperatureF => ...;
    public string? Summary { get; set; }
}
export type WeatherForecast = {
    date: DateOnly;
    temperatureC: number;
    temperatureF: number;
    summary?: null | string;
};

It's possible that we do not want to generate some properties, in that case we can use ContractGeneratorIgnore attribute:

public class WeatherForecast
{
    ...

+   [ContractGeneratorIgnore]
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);

    ...
}

Run ts-gen again after rebuilding to see that temperatureF is not present

  • Genetared DayOfWeek.ts looks like this:
export enum DayOfWeek {
    Sunday = 'Sunday',
    Monday = 'Monday',
    Tuesday = 'Tuesday',
    Wednesday = 'Wednesday',
    Thursday = 'Thursday',
    Friday = 'Friday',
    Saturday = 'Saturday',
}

Enums are generated with string constants, that means that StringEnumConverter should be used when dealing with JSON globally or [JsonConverter(typeof(StringEnumConverter))] should be placed on enums used in API, this behaviour can be changed if necessary by making custom EnumTypeBuildingContext based on TypeScriptEnumTypeBuildingContext and providing it to our CustomGenerator

  • Generated DateOnly.ts looks like this:
export type DateOnly = {
    year: number;
    month: number;
    day: number;
    dayOfWeek: DayOfWeek;
    dayOfYear: number;
    dayNumber: number;
};

Generating DateOnly type makes little sense because in JavaScript Date type is different. We can tell generator to use our own date type:

    public CustomGenerator()
    {
        var controllerBase = TypeInfo.From<ControllerBase>();
        WithTypeLocationRule(...)
+           .WithTypeRedirect(TypeInfo.From<DateOnly>(), "DateOnly", @"DataTypes\DateOnly")
            .WithTypeBuildingContext(...);
    }

After that, rerun ts-gen to see that DateOnly and DayOfWeek types are not generated, instead import statement was added to WeatherForecast.ts:

/* eslint-disable */
// TypeScriptContractGenerator's generated content
+ import { DateOnly } from './DateOnly';

export type WeatherForecast = {
    ...
};

Now you need to add your own DateOnly type in ./DataTypes/DateOnly.ts, for example:

export type DateOnly = (Date | string);

Let's add more methods to our WeatherForecastController

For convenience, you can start TypeScript.ContractGenerator in watch mode:

dotnet ts-gen -a ./bin/Debug/net7.0/AspNetCoreExample.Api.dll -o output --nullabilityMode NullableReference --watch

it will monitor folder with binaries and regenerate TypeScript on changes to it

let's add two methods and rebuild

public class WeatherForecastController : ControllerBase
{
    ...

    [HttpPost("Update/{city}")]
    public void Update(string city, [FromBody] WeatherForecast forecast, CancellationToken cancellationToken)
    {
    }

    [HttpPost("~/[action]")]
    public void Reset(int seed)
    {
    }
}

after build we will see new methods in WeatherForecastApi.ts:

    async update(city: string, forecast: WeatherForecast): Promise<void> {
        return this.makePostRequest(`/WeatherForecast/Update/${city}`, {
            
        }, {
            ...forecast,
        });
    }

    async reset(seed: number): Promise<void> {
        return this.makePostRequest(`/Reset`, {
            ['seed']: seed,
        }, {
            
        });
    }

Next, let's add method that downloads some file:

    [HttpGet("{city}")]
    public ActionResult Download(string city)
    {
        var forecast = new WeatherForecast
            {
                Date = DateOnly.FromDateTime(DateTime.Now.AddDays(1)),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = summaries[Random.Shared.Next(summaries.Length)]
            };
        return File(JsonSerializer.SerializeToUtf8Bytes(forecast), "application/json");
    }

Usually, we would like to do something like window.location = '/WeatherForecast/City' for this method, so we only need to get link to method in api. We can use [UrlOnly] attribute to achieve this:

+   [UrlOnly]
    [HttpGet("{city}")]
    public ActionResult Download(string city)

after rebuild, we will get this method in WeatherForecastApi.ts

getDownloadUrl(city: string): string {
    return `/WeatherForecast/${city}`;
}

Let's add another controller:

public class User
{
    public Guid Id { get; set; }
    public string Name { get; set; }
}

[Route("v1/users")]
public class UserController : ControllerBase
{
    [HttpPost]
    public ActionResult CreateUser([FromBody] User user) { ... }

    [HttpDelete("{userId:guid}")]
    public ActionResult DeleteUser(Guid userId) { ... }

    [HttpGet("{userId:guid}")]
    public ActionResult<User> GetUser(Guid userId) { ... }

    [HttpGet]
    public ActionResult<User[]> SearchUsers([FromQuery] string name) { ... }
}

Don't forget to add it to our TypesProvider

public class TypesProvider : IRootTypesProvider
{
    public ITypeInfo[] GetRootTypes()
    {
        return new[] 
        {
            TypeInfo.From<WeatherForecastController>(),
+           TypeInfo.From<UserController>(),
        };
    }
}

After rebuild we will get new ./Api/UserApi.ts file

/* eslint-disable */
// TypeScriptContractGenerator's generated content
import { User } from './../DataTypes/User';
import { ApiBase } from './../ApiBase/ApiBase';

export class UserApi extends ApiBase implements IUserApi {
    async createUser(user: User): Promise<void> {
        return this.makePostRequest(`/v1/users`, {}, {...user});
    }

    async deleteUser(userId: string): Promise<void> {
        return this.makeDeleteRequest(`/v1/users/${userId}`, {}, {});
    }

    async getUser(userId: string): Promise<User> {
        return this.makeGetRequest(`/v1/users/${userId}`, {}, {});
    }

    async searchUsers(name: string): Promise<User[]> {
        return this.makeGetRequest(`/v1/users`, {['name']: name}, {});
    }
};
export interface IUserApi {
    createUser(user: User): Promise<void>;
    deleteUser(userId: string): Promise<void>;
    getUser(userId: string): Promise<User>;
    searchUsers(name: string): Promise<User[]>;
}

and ./DataTypes/User.ts file

/* eslint-disable */
// TypeScriptContractGenerator's generated content

export type User = {
    id: string;
    name: string;
};
Clone this wiki locally