Generate strongly typed client API in TypeScript for ASP.NET Core through Code First approach without using Swagger/OpenAPI definitions.
Introduction
For developing client programs of ASP. NET Core Web API, Strongly Typed Client API Generators generate strongly typed client API in C# codes and TypeScript codes for minimizing repetitive tasks and improving the productivity of application developers and the quality of the products. You may then provide or publish either the generated source codes or the compiled client API libraries to yourself and other developers in your team or B2B partners.
This article is focused on generating TypeScript Client API for various JavaScript libraries and TypeScript frameworks.
Remarks
Even if you are doing JavaScript programming, you may still use WebApiClientGen
since the generated TypeScript file could be compiled into a JavaScript file. Though you won't get design time type checking and compile time type checking, you may still enjoy design time intellisense with in-source documents provided your JS editors support such feature.
Background
The development of WebApiClientGen
started in 2015 for C# .NET client API used in a WPF application. Swagger (renamed to OpenApi) has some inherent shortfalls:
- Limited number of data types
- Not supporting Generic
- ...
WebApiClientGen
was primarily designed for .NET Framework and .NET (Core), fully (95%) utilizing rich data types of .NET. Later on, I had developed the support for jQuery, initially for fun and later for production, after developing TypeScript CodeDOM.
As an application developer engaged in mostly complex business applications, I expect such efficient programming experiences:
- Strongly typed client data models mapping to the data models of the service.
- Strongly typed function prototypes mapping to the functions of derived classes of
ApiController
. - Code generations in the wholesale style like the way of WCF programming.
- Cherry-picking data models through data annotations using popular attributes like
DataContractAttribute
and JsonObjectAttribute
, etc. - Type checking at design time and compile time.
- Intellisense for client data models, function prototypes and doc comments.
- Consistent API prototypes across programming platforms: C#, TypeScript for jQuery, Angular, Aurelia, Fetch API and AXIOS
Here comes WebApiClientGen.
Presumptions
- You are developing ASP.NET Core applications, and will be developing the JavaScript libraries for the Web front end based on AJAX, with jQuery or SPA with Angular2, Vue or React.
- You and fellow developers prefer high abstraction through strongly typed functions in both the server side and the client side, and TypeScript is utilized.
- The POCO classes are used by both Web API and Entity Framework Code First, and you may not want to publish all data classes and members to client programs.
And optionally, it is better if you or your team is endorsing Trunk based development, since the design of WebApiClientGen
and the workflow of utilizing WebApiClientGen
were considering Trunk based development which is more efficient for Continuous Integration than other branching strategies like Feature Branching and Gitflow etc.
For following up this new way of developing client programs, it is better for you to have an ASP.NET Core Web API project, or a MVC project which contains Web API. You may use an existing project, or create a demo one.
Using the Code
This article is focused on the code example with jQuery. Similar code example for Angular 2+ is available at ASP.NET Web API, Angular2, TypeScript and WebApiClientGen.
The installation will also install dependent NuGet packages Fonlow.TypeScriptCodeDomCore
and Fonlow.Poco2TsCore
to the project references.
A HttpClient helper library should be copied to the Scripts folder alongside with the generated codes which will be updated every time the CodeGen is executed.
Additionally, CodeGenController.cs for triggering the CodeGen is added to the project's Controllers folder.
The CodeGenController
should be available only during development in the debug build, since the client API should be generated once for each version of the Web API.
#if DEBUG //This controller is not needed in production release,
using Fonlow.CodeDom.Web;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using System.Linq;
using System.Net;
namespace Fonlow.WebApiClientGen
{
[ApiExplorerSettings(IgnoreApi = true)]
[Route("api/[controller]")]
public class CodeGenController : ControllerBase
{
private readonly IApiDescriptionGroupCollectionProvider apiExplorer;
private readonly string webRootPath;
public CodeGenController(IApiDescriptionGroupCollectionProvider apiExplorer,
IWebHostEnvironment hostingEnvironment)
{
this.apiExplorer = apiExplorer;
this.webRootPath = hostingEnvironment.WebRootPath;
}
Remarks
- Nuget package WebApiClientGenCore does not install CodeGenController, and you ought to copy the file over.
Enable Doc Comments of Web API
In the Properties of the ASP.NET Core project, check Build/Output/Documentation file/Generate a file containing API documentation. For a complex/enterprise application, you may likely establish data models in other assemblies. Please check the same setting of these assemblies, and WebApiClientGen
will read the XML documentation files and copy the contents to the generated codes.
Step 1: Prepare JSON Config Data
Your Web API project may have POCO classes and API functions like the ones below:
namespace DemoWebApi.DemoData
{
public sealed class Constants
{
public const string DataNamespace = "http://fonlow.com/DemoData/2014/02";
}
[DataContract(Namespace = Constants.DataNamespace)]
public enum AddressType
{
[EnumMember]
Postal,
[EnumMember]
Residential,
};
[DataContract(Namespace = Constants.DataNamespace)]
public enum Days
{
[EnumMember]
Sat = 1,
[EnumMember]
Sun,
[EnumMember]
Mon,
[EnumMember]
Tue,
[EnumMember]
Wed,
[EnumMember]
Thu,
[EnumMember]
Fri
};
[DataContract(Namespace = Constants.DataNamespace)]
public class Address
{
[DataMember]
public Guid Id { get; set; }
public Entity Entity { get; set; }
public Guid EntityId { get; set; }
[DataMember]
public string Street1 { get; set; }
[DataMember]
public string Street2 { get; set; }
[DataMember]
public string City { get; set; }
[DataMember]
public string State { get; set; }
[DataMember]
public string PostalCode { get; set; }
[DataMember]
public string Country { get; set; }
[DataMember]
public AddressType Type { get; set; }
[DataMember]
public DemoWebApi.DemoData.Another.MyPoint Location;
}
[DataContract(Namespace = Constants.DataNamespace)]
public class Entity
{
public Entity()
{
Addresses = new List<Address>();
}
[DataMember]
public Guid Id { get; set; }
[DataMember(IsRequired =true)]
[System.ComponentModel.DataAnnotations.Required]
public string Name { get; set; }
[DataMember]
public IList<Address> Addresses { get; set; }
public override string ToString()
{
return Name;
}
}
[DataContract(Namespace = Constants.DataNamespace)]
public class Person : Entity
{
[DataMember]
public string Surname { get; set; }
[DataMember]
public string GivenName { get; set; }
[DataMember]
public DateTime? BirthDate { get; set; }
public override string ToString()
{
return Surname + ", " + GivenName;
}
}
[DataContract(Namespace = Constants.DataNamespace)]
public class Company : Entity
{
[DataMember]
public string BusinessNumber { get; set; }
[DataMember]
public string BusinessNumberType { get; set; }
[DataMember]
public string[][] TextMatrix
{ get; set; }
[DataMember]
public int[][] Int2DJagged;
[DataMember]
public int[,] Int2D;
[DataMember]
public IEnumerable<string> Lines;
}
...
...
namespace DemoWebApi.Controllers
{
[RoutePrefix("api/SuperDemo")]
public class EntitiesController : ApiController
{
[HttpGet]
public Person GetPerson(long id)
{
return new Person()
{
Surname = "Huang",
GivenName = "Z",
Name = "Z Huang",
BirthDate = DateTime.Now.AddYears(-20),
};
}
[HttpPost]
public long CreatePerson(Person p)
{
Debug.WriteLine("CreatePerson: " + p.Name);
if (p.Name == "Exception")
throw new InvalidOperationException("It is exception");
Debug.WriteLine("Create " + p);
return 1000;
}
[HttpPut]
public void UpdatePerson(Person person)
{
Debug.WriteLine("Update " + person);
}
[HttpPut]
[Route("link")]
public bool LinkPerson(long id, string relationship, [FromBody] Person person)
{
return person != null && !String.IsNullOrEmpty(relationship);
}
[HttpDelete]
public void Delete(long id)
{
Debug.WriteLine("Delete " + id);
}
[Route("Company")]
[HttpGet]
public Company GetCompany(long id)
{
The JSON config data below is to POST
to the CodeGen Web API:
{
"ApiSelections": {
"ExcludedControllerNames": [
"DemoWebApi.Controllers.Account",
"DemoWebApi.Controllers.FileUpload"
],
"DataModelAssemblyNames": [
"DemoWebApi.DemoData",
"DemoWebApi"
],
"CherryPickingMethods": 3
},
"ClientApiOutputs": {
"ClientLibraryProjectFolderName": "..\\DemoWebApi.ClientApi",
"GenerateBothAsyncAndSync": true,
"CamelCase": true,
"Plugins": [
{
"AssemblyName": "Fonlow.WebApiClientGen.jQuery",
"TargetDir": "Scripts\\ClientApi",
"TSFile": "WebApiJQClientAuto.ts",
"AsModule": false,
"ContentType": "application/json;charset=UTF-8"
},
{
"AssemblyName": "Fonlow.WebApiClientGen.NG2",
"TargetDir": "..\\DemoNGCli\\NGSource\\src\\ClientApi",
"TSFile": "WebApiNG2ClientAuto.ts",
"AsModule": true,
"ContentType": "application/json;charset=UTF-8"
}
]
}
}
If you have all POCO classes defined in the Web API project, you should put the assembly name of the Web API project to the array of DataModelAssemblyNames
. If you have some dedicated data model assemblies for good separation of concerns, you should put respective assembly names to the array.
TypeScriptNG2Folder is an absolute path or relative path to the Angular2 project. For example, ..\\DemoAngular2\\ClientApi indicates an Angular 2 project created as a sibling project of the Web API project.
The CodeGen generates strongly typed TypeScript interfaces from POCO classes according to CherryPickingMethods
which is described in the doc comment below:
[Flags]
public enum CherryPickingMethods
{
All = 0,
DataContract =1,
NewtonsoftJson = 2,
Serializable = 4,
AspNet = 8,
}
The default one is DataContract
for opt-in. And you may use any or combinations of methods.
Step 2: Run the DEBUG Build of the Web API Project and POST JSON Config Data to Trigger the Generation of Client API Codes
Run the Web project in IDE on IIS Express.
You then use Curl or Poster or any of your favorite client tools to POST
to http://localhost:10965/api/CodeGen, with content-type=application/json
.
Hints
So basically, you just need Step 2 to generate the client API whenever the Web API is updated, since you don't need to install the NuGet package or craft new JSON config data every time.
It shouldn't be hard for you to write some batch scripts to launch the Web API and POST the JSON config data. And I have actually drafted one for your convenience: a Powershell script file that launch the Web (API) project on DotNet Kestrel then post the JSON config data to trigger the code generation.
Publish Client API Libraries
Now you have the client API in TypeScript generated, similar to this example:
namespace DemoWebApi_DemoData_Client {
export interface Address {
city?: string;
country?: string;
id?: string;
postalCode?: string;
state?: string;
street1?: string;
street2?: string;
type?: DemoWebApi_DemoData_Client.AddressType;
location?: DemoWebApi_DemoData_Another_Client.MyPoint;
}
export enum AddressType { Postal, Residential }
export interface Company extends DemoWebApi_DemoData_Client.Entity {
BusinessNum?: string;
businessNumberType?: string;
foundDate?: Date;
registerDate?: Date;
textMatrix?: Array<Array<string>>;
int2D?: number[][];
int2DJagged?: Array<Array<number>>;
lines?: Array<string>;
}
export enum Days {
Sat = 1,
Sun = 2,
Mon = 3,
Tue = 4,
Wed = 5,
Thu = 6,
Fri = 7
}
export interface Entity {
addresses?: Array<DemoWebApi_DemoData_Client.Address>;
id?: string;
name: string;
phoneNumbers?: Array<DemoWebApi_DemoData_Client.PhoneNumber>;
web?: string;
}
...
namespace DemoWebApi_Controllers_Client {
export class Entities {
constructor(private baseUri: string = HttpClient.locationOrigin,
private httpClient: HttpClientBase = new HttpClient(),
private error?: (xhr: JQueryXHR, ajaxOptions: string,
thrown: string) => any, private statusCode?: { [key: string]: any; }) {
}
createCompany(p: DemoWebApi_DemoData_Client.Company,
callback: (data : DemoWebApi_DemoData_Client.Company) => any,
headersHandler?: () => {[header: string]: string}) {
this.httpClient.post(this.baseUri + 'api/Entities/createCompany',
p, callback, this.error, this.statusCode,
'application/json;charset=UTF-8', headersHandler);
}
createPerson(p: DemoWebApi_DemoData_Client.Person, callback: (data : number)
=> any, headersHandler?: () => {[header: string]: string}) {
this.httpClient.post(this.baseUri + 'api/Entities/createPerson',
p, callback, this.error, this.statusCode,
'application/json;charset=UTF-8', headersHandler);
}
delete(id: number, callback: (data : void) => any,
headersHandler?: () => {[header: string]: string}) {
this.httpClient.delete(this.baseUri + 'api/Entities/' + id,
callback, this.error, this.statusCode, headersHandler);
}
getCompany(id: number, callback:
(data : DemoWebApi_DemoData_Client.Company) => any,
headersHandler?: () => {[header: string]: string}) {
this.httpClient.get(this.baseUri + 'api/Entities/Company/' + id,
callback, this.error, this.statusCode, headersHandler);
}
Hints
- The PowerShell script also compiles the TS file for jQuery to a JS file.
Internal Usages
When writing client codes in some decent text editors like Visual Studio, you may get nice intellisense.
External Usages
If you would expect some external developers to use your Web API through JavaScript, you may publish the generated TypeScript client API file or the compiled JavaScript files.
For Angular
Prerequisites
Step 1: In the Web API project, import NuGet package: Fonlow.WebApiClientGenCore.NG2
Step 2: In CodeGen.json which will be posted to the Web service, add the following:
"Plugins": [
{
"AssemblyName": "Fonlow.WebApiClientGenCore.NG2",
"TargetDir": "..\\..\\..\\..\\..\\HeroesDemo\\src\\ClientApi",
"TSFile": "WebApiCoreNG2ClientAuto.ts",
"AsModule": true,
"ContentType": "application/json;charset=UTF-8",
"ClientNamespaceSuffix": ".Client",
"ContainerNameSuffix": "",
"DataAnnotationsToComments": true,
"HelpStrictMode": true
},
Generated Codes
...
export namespace DemoWebApi_Controllers_Client {
@Injectable()
export class Entities {
constructor(@Inject('baseUri') private baseUri: string = location.protocol +
'//' + location.hostname + (location.port ? ':' + location.port : '') +
'/', private http: HttpClient) {
}
createCompany(p?: DemoWebApi_DemoData_Client.Company, headersHandler?:
() => HttpHeaders): Observable<DemoWebApi_DemoData_Client.Company> {
return this.http.post<DemoWebApi_DemoData_Client.Company>
(this.baseUri + 'api/Entities/createCompany', JSON.stringify(p),
{ headers: headersHandler ? headersHandler().append('Content-Type',
'application/json;charset=UTF-8') : new HttpHeaders
({ 'Content-Type': 'application/json;charset=UTF-8' }) });
}
createPerson(p?: DemoWebApi_DemoData_Client.Person, headersHandler?:
() => HttpHeaders): Observable<number> {
return this.http.post<number>(this.baseUri + 'api/Entities/createPerson',
JSON.stringify(p), { headers: headersHandler ? headersHandler().append
('Content-Type', 'application/json;charset=UTF-8') :
new HttpHeaders({ 'Content-Type': 'application/json;charset=UTF-8' }) });
}
delete(id?: number, headersHandler?: () => HttpHeaders):
Observable<HttpResponse<string>> {
return this.http.delete(this.baseUri + 'api/Entities/' + id,
{ headers: headersHandler ? headersHandler() : undefined,
observe: 'response', responseType: 'text' });
}
getCompany(id?: number, headersHandler?: () => HttpHeaders):
Observable<DemoWebApi_DemoData_Client.Company> {
return this.http.get<DemoWebApi_DemoData_Client.Company>
(this.baseUri + 'api/Entities/Company/' + id,
{ headers: headersHandler ? headersHandler() : undefined });
}
Remarks
The support for Angular2 is available since WebApiClientGen
v1.9.0-beta in June 2016 when Angular 2 was still in RC1. And the support for Angular 2 production release is available since WebApiClientGen
v2.0. Please refer to article "ASP.NET Web API, Angular2, TypeScript and WebApiClientGen".
For Fetch API
Prerequisites
Step 1: Add NuGet package: Fonlow.WebApiClientGenCore.Fetch
Step 2: Post this payload:
{
"AssemblyName": "Fonlow.WebApiClientGenCore.Fetch",
"TargetDir": "..\\..\\..\\..\\..\\fetchapi\\src\\clientapi",
"TSFile": "WebApiCoreFetchClientAuto.ts",
"AsModule": true,
"ContentType": "application/json;charset=UTF-8",
"HelpStrictMode": true
}
Generated Codes
export namespace DemoWebApi_Controllers_Client {
export class Entities {
constructor(private baseUri: string = location.protocol + '//' +
location.hostname + (location.port ? ':' + location.port : '') + '/') {
}
createCompany(p?: DemoWebApi_DemoData_Client.Company,
headersHandler?: () => {[header: string]: string}):
Promise<DemoWebApi_DemoData_Client.Company> {
return fetch(this.baseUri + 'api/Entities/createCompany',
{ method: 'post', headers: headersHandler ?
Object.assign(headersHandler(), { 'Content-Type':
'application/json;charset=UTF-8' }): { 'Content-Type':
'application/json;charset=UTF-8' },
body: JSON.stringify(p) }).then(d => d.json());
}
createPerson(p?: DemoWebApi_DemoData_Client.Person,
headersHandler?: () => {[header: string]: string}): Promise<number> {
return fetch(this.baseUri + 'api/Entities/createPerson',
{ method: 'post', headers: headersHandler ? Object.assign(headersHandler(),
{ 'Content-Type': 'application/json;charset=UTF-8' }):
{ 'Content-Type': 'application/json;charset=UTF-8' },
body: JSON.stringify(p) }).then(d => d.json());
}
delete(id?: number, headersHandler?: () => {[header: string]: string}):
Promise<Response> {
return fetch(this.baseUri + 'api/Entities/' + id,
{ method: 'delete', headers: headersHandler ? headersHandler() : undefined });
}
getCompany(id?: number, headersHandler?: () =>
{[header: string]: string}): Promise<DemoWebApi_DemoData_Client.Company> {
return fetch(this.baseUri + 'api/Entities/Company/' + id,
{ method: 'get', headers: headersHandler ? headersHandler() :
undefined }).then(d => d.json());
}
Points of Interests
Simplicity and Consistency
As you have seen, the APIs of the generated codes for jQuery, Angular and Aurelia are looking almost identical. Unless you are using the callback, the application codes look identical.
Angular and Aurelia are TypeScript Frameworks coming with their own HttpClient
component. jQuery as a library comes with jQuery.ajax
, while helper library HttpClient.ts makes the API and the implementation of the generated codes simple and consistent.
Many JavaScript libraries like React and Vue.js do not come with a built-in HTTP request library or component, and JS programmers have been typically using AXIOS or Fetch API. You may find some examples about how React and Vue.js utilize the generated codes for AXIOS:
Remarks
The Babel team behind React had changed their mind and then supported namespace in March 2019, as documented in issue 60.
Implementation Details of the Service
While ASP.NET Core MVC and Web API may use either System.Text.Json or NewtonSoft.Json for JSON applications, NewtonSoft.Json can handle well POCO classes decorated by DataContractAttribute.
The CLR namespaces will be translated to TypeScript namespaces through replacing dot with underscore and adding Client
as suffix. For example, namespace My.Name.space
will be translated to My_Name_space_Client
.
From a certain point of view, the one to one mapping between the service namespace/function names and the client namespace/function names is exposing the implementation details of the service, which generally is not recommended. However, traditional RESTful client programming requires programmers to be aware of the URL query templates of service functions, and the query templates are of implementation details of the service. So both approaches expose the implementation details of the service at some degree, but with different consequences.
To client developers, classic function prototype like:
ReturnType DoSomething(Type1 t1, Type2 t2 ...)
is the API function, and the rest is the technical implementation details of transportation: TCP/IP, HTTP, SOAP, resource-oriented, CRUD-based URIs, RESTful, XML and JSON, etc. The function prototype and a piece of API document should be good enough for calling the API functions. Client developers should not have to care about those implementation details of transportation, at least when the operation is successful. Only when errors kick in, developers will have to care about the technical details. For example, in SOAP based web services, you have to know about SOAP faults; and in RESTful Web services, you may have to deal with HTTP status codes and Response.
And the query templates give little sense of semantic meaning of the API functions. In contrast, WebApiClientGen
names the client functions after the service functions, just as SvcUtil.exe in WCF will do by default, so the client functions generated have good semantic meaning as long as you as the service developers had named the service functions after good semantic names.
In the big picture of SDLC covering both the service development and the client developments, the service developers have the knowledge of semantic meaning of service functions, and it is generally a good programming practice to name functions after functional descriptions. Resource-oriented CRUD may have semantic meaning or just become a technical translation from functional descriptions.
WebApiClientGen
copies the doc comments of your Web API to JsDoc3
comments in the generated TypeScript codes, thus you have little need of reading the Help Pages generated by MVC, and your client programming with the service will become more seamless.
Continuous Integration
Hints
And it shouldn't be hard to write scripts to automate some steps altogether for Continuous Integration. And you can find examples at
- WebApiClientGen
- WebApiClientGen Examples for .NET Framework, .NET Standard, Xamarin, and vue TS.
- .NET Core Demo for ASP.NET Core MVC, Web API, ASP.NET Core + Angular, MAUI, fetchAPI, vue TS and React TS.
Remarks
The landscapes of developing Web services and clients have been rapidly changing. Since the first release of WebApiClientGen
in September 2015, there came Open API Definition Format run by Open API Initiative, found in November 2015. Hopefully, the initiative will address some shortfalls of Swagger specification 2.0, particularly for handling the decimal / monetary type. Nevertheless, the SDLC utilizing WebApiClientGen
is optimized for developing client programs upon ASP.NET Web API and ASP.NET Core Web API.
SDLC
So basically, you craft Web API codes including API controllers and data models, and then execute CreateClientApi.ps1. That's it. WebApiClientGen
and CreateClientApi.ps1 will do the rest for you.
Teamwork
This section describes some basic scenarios of teamwork. Situations and contexts may vary in different companies and teams, thus you shall tune your team practices accordingly.
Your team has a backend developer Brenda working on the Web API, and a frontend developer Frank working on the frontend. Each development machine has the integration testing environment properly setup, so most CI works could be done on each development machine without the team CI server. Trunk base development is the default branching practice.
1 Repository Including Backend Codes and Frontend Codes
- Brenda wrote some new Web API codes, and build.
- Brenda executes CreateClientApi.ps1 to generate client codes.
- Brenda writes and runs some basic integration test cases against the Web API.
- Brenda commits/pushes the changes to the main development branch or the trunk.
- Frank updates/pulls the changes, builds, and runs the test cases.
- Frank develops new frontend features based on the new Web APIs and client APIs.
1 Backend Repository and 1 Frontend Repository
Brenda adjusted CodeGen.json that will direct the generated codes to the client API folders in the working folder of the frontend repository.
- Brenda wrote some new Web API codes, and build.
- Brenda executes CreateClientApi.ps1 to generate client codes.
- Brenda writes and runs some basic integration test cases against the Web API.
- Brenda commits/pushes the changes to the main development branch or the trunk of both repositories.
- Frank updates/pulls the changes with both repositories, builds, and runs the test cases.
- Frank develops new frontend features based on the new Web APIs and client APIs.
References
History
- 17th October, 2023: Initial version