Click here to Skip to main content
13,146,402 members (49,260 online)
Click here to Skip to main content
Add your own
alternative version

Stats

50.8K views
65 bookmarked
Posted 10 Nov 2015

Generate TypeScript Client API for ASP.NET Web API

, 18 Apr 2017
Rate this:
Please Sign up or sign in to vote.
Generate strongly typed client API in TypeScript for ASP.NET Web API

Introduction

Strongly Typed Client API Generators generate strongly typed client API in C# codes and TypeScript codes for minimizing repetitive tasks and improving productivity and quality when developing client programs. 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 project provides these products:

  1. Code generator for strongly typed client API in C# supporting desktop, Universal Windows, Android and iOS.
  2. Code generator for strongly typed client API in TypeScript for jQuery and Angular 2.
  3. TypeScript CodeDOM, a CodeDOM component for TypeScript, derived from CodeDOM of .NET Framework.
  4. POCO2TS.exe, a command line program that generates TypsScript interfaces from POCO classes.
  5. Fonlow.Poco2Ts, a component that generates TypsScript interfaces from POCO classes

This article is focused on generating TypeScript Client API for jQuery. 

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 in WebApiClientGen v2.0. Please refer to article "ASP.NET Web API, Angular2, TypeScript and WebApiClientGen"

Background

If you have ever developed SOAP base Web services using WCF, you might have enjoyed using the client API codes generated by SvcUtil.exe or Web Service References of Visual Studio IDE. When moving to Web API, I felt that I had got back to the Stone Age, since I had to do a lot of data type checking at design time using my precious brain power while computes should have done the job.

I had developed some RESTful Web services on top of IHttpHandler/IHttpModule in 2010 for some Web services that did not handle strongly typed data but arbitrary data like documents and streams. However, I have been getting more Web projects with complex business logic and data types, and I would utilize highly abstraction and semantic data types througout SDLC.

I see that ASP.NET Web API does support highly abstraction and strongly typed function prototypes through class ApiController, and ASP.NET MVC framework optionally provides nicely generated Help Page describing the API functions. However, after developing the Web API, I had to hand-craft some very primitive and repetitive client codes to consume the Web services. If the Web API was developed by others, I had to read the online help pages and then craft.

Therefore, I had searched and tried to find some solutions that could release me from crafting primitive and repetitive codes so I could focus on building business logic at the client sides on higher technical abstractions. Here's a list of open source projects assisting the development of client programs:

  1. WADL
  2. RAML with .NET
  3. WebApiProxy
  4. Swashbuckle
  5. AutoRest
  6. OData
  7. TypeLITE
  8. TypeWriter

While these solutions could generated strongly typed client codes and reduce repetitive tasks at some degree, however, I found none of them could give me all the efficient programming experiences that I would expect:

  1. Strongly typed client data models mapping to the data models of the service.
  2. Strongly typed function prototypes mapping to the functions of derived classes of ApiController.
  3. Code generations in the wholesale style like the way of WCF programming.
  4. Cherry-picking data models through data annotations using popular attributes like DataContractAttribute and JsonObjectAttribute, etc.
  5. Type checking at design time and compile time.
  6. Intellisense for client data models, function prototypes and doc comments.

Here comes WebApiClientGen.

Presumptions

  1. You are developing ASP.NET Web API 2.x applications, and will be developing the JavaScript libraries for the Web front end based on AJAX, with jQuery or SPA with Angular2.
  2. You and fellow developers prefer highly abstraction through strongly typed functions in both the server side and the client side, and TypeScript is utilized.
  3. 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 wordflow of using 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 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".

Step 0: Install NuGet package WebApiClientGen to the Web API Project

The installation will also install dependent NuGet packages Fonlow.TypeScriptCodeDOM and Fonlow.Poco2Ts to the project references.

The NuGet package will add 2 TS files to the ~/Scripts/ClientApi folder of the project, one is HttpClient.ts, and the other is WebApiClientAuto.ts which will be replaced 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, 
	// since the client API should be generated during development of the Web Api.
...

namespace Fonlow.WebApiClientGen
{
    [System.Web.Http.Description.ApiExplorerSettings(IgnoreApi = true)]//this controller is a 
			//dev backdoor during development, no need to be visible in ApiExplorer.
    public class CodeGenController : ApiController
    {
        /// <summary>
        /// Trigger the API to generate WebApiClientAuto.cs for an established client API project.
        /// POST to  http://localhost:10965/api/CodeGen with json object CodeGenParameters
        /// </summary>
        /// <param name="parameters"></param>
        /// <returns>OK if OK</returns>
        [HttpPost]
        public string TriggerCodeGen(CodeGenParameters parameters)
        {
...
        }
    }

Remarks

CodeGenController is installed in YourMvcOrWebApiProject/Controllers, even though the scaffolding of a MVC project might has folder API for derived classes of ApiController. However, generally it is good to have the Web API implemented in a standalone Web API project. And if you want the MVC project and the Web API project run in the same Website, you may just installed the Web API as an application of the MVC Website.

Enable Doc Comments of Web API

In C:\YourWebSlnPath\Your.WebApi\Areas\HelpPage\App_Start\HelpPageConfig.cs, there is such line:

//config.SetDocumentationProvider(new XmlDocumentationProvider(HttpContext.Current.Server.MapPath("~/App_Data/XmlDocument.xml")));

Uncomment it and make it be like this:

config.SetDocumentationProvider(new XmlDocumentationProvider(HttpContext.Current.Server.MapPath("~/bin/Your.WebApi.xml")));

In the Build tab of the project Properties page, check Output/XML Document File and set "bin\Your.WebApi.xml", while the the output path is "bin" by default.

If you have other assemblies for data models, you may do the same to ensure doc comments to be generated and copied over to the client API.

Step 1: Prepare JSON Config Data

Your Web API project may have POCO classes and API functions like the ones blow.  [Full code examples for data models and ApiController]

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; }

        /// <summary>
        /// Foreign key to Entity
        /// </summary>
        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)]//MVC and Web API does not care
        [System.ComponentModel.DataAnnotations.Required]//MVC and Web API care about only this
        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
    {
        /// <summary>
        /// Get a person
        /// </summary>
        /// <param name="id">unique id of that guy</param>
        /// <returns>person in db</returns>
        [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"
        ],

        "DataModelAssemblyNames": [
            "DemoWebApi.DemoData",
            "DemoWebApi"
        ],
        "CherryPickingMethods": 1
    },

    "ClientApiOutputs": {
        "ClientLibraryProjectFolderName": "DemoWebApi.ClientApi",
        "GenerateBothAsyncAndSync": true,

        "CamelCase": true,
        "TypeScriptJQFolder": "Scripts\\ClientApi",
        "TypeScriptNG2Folder": "..\\DemoAngular2\\ClientApi"

    }
}

It is recommended to save the JSON config data into a file like this one located in the Web API project folder.

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:

/// <summary>
/// Flagged options for cherry picking in various development processes.
/// </summary>
[Flags]
public enum CherryPickingMethods
{
    /// <summary>
    /// Include all public classes, properties and properties.
    /// </summary>
    All = 0,

    /// <summary>
    /// Include all public classes decorated by DataContractAttribute,
    /// and public properties or fields decorated by DataMemberAttribute.
    /// And use DataMemberAttribute.IsRequired
    /// </summary>
    DataContract =1,

    /// <summary>
    /// Include all public classes decorated by JsonObjectAttribute,
    /// and public properties or fields decorated by JsonPropertyAttribute.
    /// And use JsonPropertyAttribute.Required
    /// </summary>
    NewtonsoftJson = 2,

    /// <summary>
    /// Include all public classes decorated by SerializableAttribute,
    /// and all public properties or fields
    /// but excluding those decorated by NonSerializedAttribute.
    /// And use System.ComponentModel.DataAnnotations.RequiredAttribute.
    /// </summary>
    Serializable = 4,

    /// <summary>
    /// Include all public classes, properties and properties.
    /// And use System.ComponentModel.DataAnnotations.RequiredAttribute.
    /// </summary>
    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 IIS Express then post the JSON config file to trigger the code generation.

 

Publish Client API Libraries

Now you have the client API in TypeScript generated, similar to this example:

/// <reference path="../typings/jquery/jquery.d.ts" />
/// <reference path="HttpClient.ts" />
namespace DemoWebApi_DemoData_Client {
    export enum AddressType {Postal, Residential}

    export enum Days {Sat=1, Sun=2, Mon=3, Tue=4, Wed=5, Thu=6, Fri=7}

    export interface Address {
        Id?: string;
        Street1?: string;
        Street2?: string;
        City?: string;
        State?: string;
        PostalCode?: string;
        Country?: string;
        Type?: DemoWebApi_DemoData_Client.AddressType;
        Location?: DemoWebApi_DemoData_Another_Client.MyPoint;
    }

    export interface Entity {
        Id?: string;
        Name: string;
        Addresses?: Array<DemoWebApi_DemoData_Client.Address>;
    }

    export interface Person extends DemoWebApi_DemoData_Client.Entity {
        Surname?: string;
        GivenName?: string;
        BirthDate?: Date;
    }

    export interface Company extends DemoWebApi_DemoData_Client.Entity {
        BusinessNumber?: string;
        BusinessNumberType?: string;
        TextMatrix?: Array<Array<string>>;
        Int3D?: Array<Array<Array<number>>>;
        Lines?: Array<string>;
    }
}

namespace DemoWebApi_DemoData_Another_Client {
    export interface MyPoint {
        X?: number;
        Y?: number;
    }
}

namespace DemoWebApi_Controllers_Client {

    export class Entities {
        httpClient: HttpClient;
        constructor(public baseUri?: string, public error?: 
        (xhr: JQueryXHR, ajaxOptions: string, thrown: string) => 
        any, public statusCode?: { [key: string]: any; }){
            this.httpClient = new HttpClient();
        }

        /**
         * Get a person
         * GET api/Entities/{id}
         * @param {number} id unique id of that guy
         * @return {DemoWebApi_DemoData_Client.Person} person in db
         */
        GetPerson(id: number, callback: (data : DemoWebApi_DemoData_Client.Person) => any){
            this.httpClient.get(encodeURI(this.baseUri + 
            'api/Entities/'+id), callback, this.error, this.statusCode);
        }

        /**
         * POST api/Entities
         * @param {DemoWebApi_DemoData_Client.Person} person
         * @return {number}
         */
        CreatePerson(person: DemoWebApi_DemoData_Client.Person, 
        	callback: (data : number) => any){
            this.httpClient.post(encodeURI(this.baseUri + 
            'api/Entities'), person, callback, this.error, this.statusCode);
        }

        /**
         * PUT api/Entities
         * @param {DemoWebApi_DemoData_Client.Person} person
         * @return {void}
         */
        UpdatePerson(person: DemoWebApi_DemoData_Client.Person, callback: (data : void) => any){
            this.httpClient.put(encodeURI(this.baseUri + 
            'api/Entities'), person, callback, this.error, this.statusCode);
        }

        /**
         * DELETE api/Entities/{id}
         * @param {number} id
         * @return {void}
         */
        Delete(id: number, callback: (data : void) => any){
            this.httpClient.delete(encodeURI(this.baseUri + 
            'api/Entities/'+id), callback, this.error, this.statusCode);
        }
    }

    export class Values {
        httpClient: HttpClient;
        constructor(public baseUri?: string, public error?: 
        (xhr: JQueryXHR, ajaxOptions: string, thrown: string) => any, 
        public statusCode?: { [key: string]: any; }){
            this.httpClient = new HttpClient();
        }

        /**
         * GET api/Values
         * @return {Array<string>}
         */
        Get(callback: (data : Array<string>) => any){
            this.httpClient.get(encodeURI(this.baseUri + 
            'api/Values'), callback, this.error, this.statusCode);
        }

        /**
         * GET api/Values/{id}?name={name}
         * @param {number} id
         * @param {string} name
         * @return {string}
         */
        GetByIdAndName(id: number, name: string, callback: (data : string) => any){
            this.httpClient.get(encodeURI(this.baseUri + 
            'api/Values/'+id+'?name='+name), 
            callback, this.error, this.statusCode);
        }

        /**
         * POST api/Values
         * @param {string} value
         * @return {string}
         */
        Post(value: {'':string}, callback: (data : string) => any){
            this.httpClient.post(encodeURI(this.baseUri + 
            'api/Values'), value, callback, this.error, this.statusCode);
        }

        /**
         * PUT api/Values/{id}
         * @param {number} id
         * @param {string} value
         * @return {void}
         */
        Put(id: number, value: {'':string}, callback: (data : void) => any){
            this.httpClient.put(encodeURI(this.baseUri + 
            'api/Values/'+id), value, callback, this.error, this.statusCode);
        }

        /**
         * DELETE api/Values/{id}
         * @param {number} id
         * @return {void}
         */
        Delete(id: number, callback: (data : void) => any){
            this.httpClient.delete(encodeURI(this.baseUri + 
            'api/Values/'+id), callback, this.error, this.statusCode);
        }
    }
}

 

Hints:

If you want the TypeScript codes generated to conform to the camel casing of javascript and JSON, you may add the following line in class WebApiConfig of scaffolding codes of Web API:

config.Formatters.JsonFormatter.SerializerSettings.ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver();

then the property names and function names will be in camel casing, provided the respective names in C# are in Pascal casing. For more details, please check camelCasing or PascalCasing.

Internal Usages

The TypeScript file WebApiClientAuto.ts generated in ~Scripts/ClientApi is linked to your MVC/Web API project by default, thus it will be usable immediately in Visual Studio IDE at design time.

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 or compiled JavaScript files, along with the help pages generated by ASP.NET MVC framework.

Points of Interests

While ASP.NET MVC and Web API use 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 certain point of view, the one to one mapping between the service namespaces/function names and the client namespaces/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 transportations: 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 of handling errors. For example, in SOAP base 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 development, 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.

Hints
And it shouldn't be hard to write scripts to automate some steps altogether for Continuous Integration.

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.

 

 

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Zijian
Software Developer
Australia Australia
I started my IT career in programming on different embedded devices since 1992, such as credit card readers, smart card readers and Palm Pilot.

Since 2000, I have mostly been developing business applications on Windows platforms while also developing some tools for myself and developers around the world, so we developers could focus more on delivering business values rather than repetitive tasks of handling technical details.

Beside technical works, I enjoy reading literatures, playing balls, cooking and gardening.

You may also be interested in...

Pro
Pro

Comments and Discussions

 
QuestionBrilliant Pin
Matthew Copeland11-Apr-17 9:53
memberMatthew Copeland11-Apr-17 9:53 
AnswerRe: Brilliant Pin
Zijian18-Apr-17 15:11
memberZijian18-Apr-17 15:11 
Questionabout the "TypeScriptJQFolder": "Scripts\\ClientApi", and "TypeScriptNG2Folder": "..\\DemoAngular2\\ClientApi Pin
Kimball Johnson3-Jul-16 20:40
memberKimball Johnson3-Jul-16 20:40 
AnswerRe: about the "TypeScriptJQFolder": "Scripts\\ClientApi", and "TypeScriptNG2Folder": "..\\DemoAngular2\\ClientApi Pin
Zijian13-Jul-16 10:45
memberZijian13-Jul-16 10:45 
QuestionAsp.Net 5 support Pin
Layinka10-Apr-16 7:43
memberLayinka10-Apr-16 7:43 
Questionwhy? Pin
Member 800306316-Dec-15 0:57
memberMember 800306316-Dec-15 0:57 
AnswerRe: why? Pin
Zijian16-Dec-15 14:00
memberZijian16-Dec-15 14:00 
GeneralMy vote of 3 Pin
Matthew Dennis11-Nov-15 7:23
adminMatthew Dennis11-Nov-15 7:23 
GeneralMy vote of 3 Pin
Matthew Dennis11-Nov-15 7:01
adminMatthew Dennis11-Nov-15 7:01 
GeneralRe: My vote of 3 Pin
Nelek19-Nov-15 3:20
protectorNelek19-Nov-15 3:20 
PraiseAwsome Pin
Jean-Pierre Bachmann11-Nov-15 2:26
professionalJean-Pierre Bachmann11-Nov-15 2:26 
GeneralRe: Awsome Pin
Zijian11-Nov-15 12:01
memberZijian11-Nov-15 12:01 
GeneralMy vote of 4 Pin
Santhk10-Nov-15 20:29
professionalSanthk10-Nov-15 20:29 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Permalink | Advertise | Privacy | Terms of Use | Mobile
Web03 | 2.8.170915.1 | Last Updated 18 Apr 2017
Article Copyright 2015 by Zijian
Everything else Copyright © CodeProject, 1999-2017
Layout: fixed | fluid