Click here to Skip to main content
15,887,596 members
Articles / Programming Languages / Typescript

Generate Typed Forms of Angular Reactive Forms from Swagger / OpenAPI definitions

Rate me:
Please Sign up or sign in to vote.
5.00/5 (2 votes)
10 Jan 2024CPOL4 min read 4.1K   1
Construct Angular Reactive Forms of client data models through generated Angular TypeScript codes
For Angular developers consuming Swagger / OpenAPI definitions, construct typed Angular Reactive Forms of client data models through generated codes.

Introduction

You are crafting Angular apps consuming a 3rd party Web API and the 3rd party vendor provides OpenAPI definition files.

When developing fat Web clients (SPA, PWA) of a complex business application using Angular 2+, you prefer to use Reactive Forms over Template-Driven Forms for data entries.

This article introduces how to use OpenApiClientGen to generate interfaces for data models, client API functions to the backend, and Typed Forms codes including validations, friendly to the strict mode.

Background

When using Reactive Forms, as of Angular 17, you have to manually craft FormGroups, FormControls and FormArray, etc., with logical structures reassembling the client data models. And every time the Web API is upgraded with changes in data models, you will have to adjust respective codes of Reactive Forms.

For complex business applications, there should be a single source of truth for validation rules for the sake of productivity, quality and maintainability, as well as continuous integration. Where should such single source of truth exist?

Generally, it often should be in the backend, if a backend is needed. And these days, the definition of validations is presented as Swagger / OpenAPI definition, comparable to WSDL of SOAP Web service.

Would it be nice to automate the construction of FormGroups along with the validation rules at some degree?

Surely, this is what many Angular developers have been looking for. Likely you have found some through Googling with keywords like "swagger generate formgroup angular", and this is a list of tools that I had located:

However, as of the tests in January 2024, none of them can really handle petstore.json or petstore.yaml, though some simple ones like AddressForms are OK.

It is quite possible that I have overlooked something good enough for complex business applications (complex Swagger / OpenAPI definitions). If you find any, please leave your comment.

What extra features beneficial to Angular application developers does OpenApiClientGen provides?

  • Typed Forms
  • Friendly to the strict mode
  • Along with interfaces for data models and API functions for the backend Web API

Remarks:

Using the Code

Go to releases to download OpenApiClientGenxxx.zip and extract to a local folder. Alternatively, you may build from the source codes of .NET 7. The repository also provides a build script to build for MacOS.

Prerequisites

  • .NET 7 or above

How to Generate

When running Fonlow.OpenApiClientGen.exe without parameter, you will see the following hints:

BAT
Parameter 1: Open API YAML/JSON definition file
Parameter 2: Settings file in JSON format.
Example: 
  Fonlow.OpenApiClientGen.exe my.yaml
  Fonlow.OpenApiClientGen.exe my.yaml myproj.json
  Fonlow.OpenApiClientGen.exe my.yaml ..\myproj.json</code>

A typical CodeGen JSON file is like this "DemoCodeGen.json":

JavaScript
{
	"ClientNamespace": "My.Pet.Client",
	"ClientLibraryProjectFolderName": "./Tests/DemoClientApi",
	"ContainerClassName": "PetClient",
	"ClientLibraryFileName": "PetAuto.cs",
	"ActionNameStrategy": 4,
	"UseEnsureSuccessStatusCodeEx": true,
	"DecorateDataModelWithDataContract": true,
	"DataContractNamespace": "http://pet.domain/2020/03",
	"DataAnnotationsEnabled": true,
	"DataAnnotationsToComments": true,
	"HandleHttpRequestHeaders": true,

	"Plugins": [
		{
			"AssemblyName": "Fonlow.OpenApiClientGen.NG2FormGroup",
			"TargetDir": "./ng2/src/clientapi",
			"TSFile": "ClientApiAuto.ts",
			"AsModule": true,
			"ContentType": "application/json;charset=UTF-8"
		}
	]

}

Generated TypeScript Codes

Pet.ts from pet.yaml

TypeScript
export interface Pet {

    /** Pet ID */
    id?: number | null;

    /** Categories this pet belongs to */
    category?: Category;

    /**
     * The name given to a pet
     * Required
     */
    name: string;

    /**
     * The list of URL to a cute photos featuring pet
     * Required
     * Maximum items: 20
     */
    photoUrls: Array<string>;
    friend?: Pet;

    /**
     * Tags attached to the pet
     * Minimum items: 1
     */
    tags?: Array<Tag>;

    /** Pet status in the store */
    status?: PetStatus | null;

    /** Type of a pet */
    petType?: string | null;
}
export interface PetFormProperties {

    /** Pet ID */
    id: FormControl<number | null | undefined>,

    /**
     * The name given to a pet
     * Required
     */
    name: FormControl<string | null | undefined>,

    /** Pet status in the store */
    status: FormControl<PetStatus | null | undefined>,

    /** Type of a pet */
    petType: FormControl<string | null | undefined>,
}
export function CreatePetFormGroup() {
    return new FormGroup<PetFormProperties>({
        id: new FormControl<number | null | undefined>(undefined),
        name: new FormControl<string | null | undefined>
                             (undefined, [Validators.required]),
        status: new FormControl<PetStatus | null | undefined>(undefined),
        petType: new FormControl<string | null | undefined>(undefined),
    });
}

/** A representation of a dog */
export interface Dog extends Pet {

    /**
     * The size of the pack the dog is from
     * Required
     * Minimum: 1
     */
    packSize: number;
}

/** A representation of a dog */
export interface DogFormProperties extends PetFormProperties {

    /**
     * The size of the pack the dog is from
     * Required
     * Minimum: 1
     */
    packSize: FormControl<number | null | undefined>,
}
export function CreateDogFormGroup() {
    return new FormGroup<DogFormProperties>({
        id: new FormControl<number | null | undefined>(undefined),
        name: new FormControl<string | null | undefined>
              (undefined, [Validators.required]),
        status: new FormControl<PetStatus | null | undefined>(undefined),
        petType: new FormControl<string | null | undefined>(undefined),
        packSize: new FormControl<number | null | undefined>
                  (undefined, [Validators.required, Validators.min(1)]),
    });
}

/** A representation of a cat */
export interface Cat extends Pet {

    /**
     * The measured skill for hunting
     * Required
     */
    huntingSkill: CatHuntingSkill;
}

/** A representation of a cat */
export interface CatFormProperties extends PetFormProperties {

    /**
     * The measured skill for hunting
     * Required
     */
    huntingSkill: FormControl<CatHuntingSkill | null | undefined>,
}
export function CreateCatFormGroup() {
    return new FormGroup<CatFormProperties>({
        id: new FormControl<number | null | undefined>(undefined),
        name: new FormControl<string | null | undefined>
                             (undefined, [Validators.required]),
        status: new FormControl<PetStatus | null | undefined>(undefined),
        petType: new FormControl<string | null | undefined>(undefined),
        huntingSkill: new FormControl<CatHuntingSkill | null | undefined>
                      (undefined, [Validators.required]),
    });
}

export enum CatHuntingSkill
       { clueless = 0, lazy = 1, adventurous = 2, aggressive = 3 }

@Injectable()
export class PetClient {
    constructor(@Inject('baseUri') private baseUri: string = location.protocol +
    '//' + location.hostname + (location.port ? ':' + location.port : '') + '/',
    private http: HttpClient) {
    }

    /**
     * Add a new pet to the store
     * Add new pet to the store inventory.
     * Post pet
     * @param {Pet} requestBody Pet object that needs to be added to the store
     * @return {void}
     */
    AddPet(requestBody: Pet): Observable<HttpResponse<string>> {
        return this.http.post(this.baseUri + 'pet', JSON.stringify(requestBody),
        { headers: { 'Content-Type': 'application/json;charset=UTF-8' },
          observe: 'response', responseType: 'text' });
    }

    /**
     * Update an existing pet
     * Put pet
     * @param {Pet} requestBody Pet object that needs to be added to the store
     * @return {void}
     */
    UpdatePet(requestBody: Pet): Observable<HttpResponse<string>> {
        return this.http.put(this.baseUri + 'pet', JSON.stringify(requestBody),
        { headers: { 'Content-Type': 'application/json;charset=UTF-8' },
          observe: 'response', responseType: 'text' });
    }

    /**
     * Find pet by ID
     * Returns a single pet
     * Get pet/{petId}
     * @param {number} petId ID of pet to return
     * @return {Pet} successful operation
     */
    GetPetById(petId: number): Observable<Pet> {
        return this.http.get<Pet>(this.baseUri + 'pet/' + petId, {});
    }

    /**
     * Deletes a pet
     * Delete pet/{petId}
     * @param {number} petId Pet id to delete
     * @return {void}
     */
    DeletePet(petId: number): Observable<HttpResponse<string>> {
        return this.http.delete(this.baseUri + 'pet/' + petId,
                               { observe: 'response', responseType: 'text' });
    }

Remarks

  • While interfaces supports well the inheritance between Pet and Cat/Dog, Typed Forms does not have inherent support for inheritance as mentioned in #47091 and #49374 as of Angular 17. So the code gen has to repetitively create FormControls for Cat & Dog. Nevertheless, this is better than that you type or copy & paste repetitively.
  • For properties of complex types like "tags?: Array<Tag>", the code gen won't create nested FormGroup, as explained below.

Nested Complex Object or Array

When you was studying Angular, likely you had walked through Tour of Heroes, so I would use the extended demo: HeroesDemo to further explain.

Depending on the overall UX design, business constraints and technical constraints, you make respective design decisions during application programming when constructing Angular reactive forms. This is why plugin NG2FormGroup skips properties of complex types and array. However, if your design decision is to add and update a complex object with nested structures always in one go, it is still easy to utilize the generated codes, as demonstrated below (HeroesDemo):

JavaScript
export interface Hero {
    address?: DemoWebApi_DemoData_Client.Address;
    death?: Date | null;
    dob?: Date | null;
    id?: number | null;

    /**
     * Required
     * String length: inclusive between 2 and 120
     */
    name?: string | null;
    phoneNumbers?: Array<DemoWebApi_DemoData_Client.PhoneNumber>;
}

export namespace DemoWebApi_DemoData_Client {
    export interface Address {

        /** String length: inclusive between 2 and 50 */
        city?: string | null;

        /** String length: inclusive between 2 and 30 */
        country?: string | null;
        id?: string | null;

        /** String length: inclusive between 2 and 10 */
        postalCode?: string | null;

        /** String length: inclusive between 2 and 30 */
        state?: string | null;

        /** String length: inclusive between 2 and 100 */
        street1?: string | null;

        /** String length: inclusive between 2 and 100 */
        street2?: string | null;
        type?: DemoWebApi_DemoData_Client.AddressType | null;

        /**
         * It is a field
         */
        location?: DemoWebApi_DemoData_Another_Client.MyPoint;
    }
    
    export interface PhoneNumber {

        /** Max length: 120 */
        fullNumber?: string | null;
        phoneType?: DemoWebApi_DemoData_Client.PhoneType | null;
    }
JavaScript
export interface HeroFormProperties {
    death: FormControl<Date | null | undefined>,
    dob: FormControl<Date | null | undefined>,
    emailAddress: FormControl<string | null | undefined>,
    id: FormControl<number | null | undefined>,

    /**
     * Required
     * String length: inclusive between 2 and 120
     */
    name: FormControl<string | null | undefined>,

    /** Min length: 6 */
    webAddress: FormControl<string | null | undefined>,
}
export function CreateHeroFormGroup() {
    return new FormGroup<HeroFormProperties>({
        death: new FormControl<Date | null | undefined>(undefined),
        dob: new FormControl<Date | null | undefined>(undefined),
        emailAddress: new FormControl<string | null | undefined>
                                      (undefined, [Validators.email]),
        id: new FormControl<number | null | undefined>(undefined),
        name: new FormControl<string | null | undefined>(undefined,
        [Validators.required, Validators.maxLength(120), Validators.minLength(2)]),
        webAddress: new FormControl<string | null | undefined>
                    (undefined, [Validators.minLength(6),
                     Validators.pattern('https?:\\/\\/(www\\.)?
                     [-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]
                     {1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)')]),
    });
}

Through inheritance and composition, in the application codes, you create a FormGroup including all properties of the extended Hero type.

JavaScript
import { Location } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { FormArray, FormGroup } from '@angular/forms';
import { ActivatedRoute, Params } from '@angular/router';
import { DemoWebApi_Controllers_Client, DemoWebApi_DemoData_Client } 
         from '../../clientapi/WebApiCoreNG2FormGroupClientAuto';

export interface HeroWithNestedFormProperties 
       extends DemoWebApi_Controllers_Client.HeroFormProperties {
    address?: FormGroup<DemoWebApi_DemoData_Client.AddressFormProperties>,
    phoneNumbers?: FormArray<FormGroup
                   <DemoWebApi_DemoData_Client.PhoneNumberFormProperties>>,
}

export function CreateHeroWithNestedFormGroup() {
    const fg: FormGroup<HeroWithNestedFormProperties> = 
              DemoWebApi_Controllers_Client.CreateHeroFormGroup();
    fg.controls.address = DemoWebApi_DemoData_Client.CreateAddressFormGroup();
    fg.controls.phoneNumbers = new FormArray<FormGroup
                <DemoWebApi_DemoData_Client.PhoneNumberFormProperties>>([]);
    return fg;
}

@Component({
    selector: 'app-hero-detail',
    templateUrl: './hero-detail.component.html'
})
export class HeroDetailComponent implements OnInit {
    hero?: DemoWebApi_Controllers_Client.Hero;
    heroForm: FormGroup<HeroWithNestedFormProperties>;
    constructor(
        private heroService: DemoWebApi_Controllers_Client.Heroes,
        private route: ActivatedRoute,
        private location: Location
    ) {
        this.heroForm = CreateHeroWithNestedFormGroup();
    }
    ngOnInit(): void {
        this.route.params.forEach((params: Params) => {
            const id = +params['id'];
            this.heroService.getHero(id).subscribe({
                next: hero => {
                    if (hero) {
                        this.hero = hero;
                        this.heroForm.patchValue(hero); // populate properties 
                                      // including composit ones except nested array.
                        if (this.hero.phoneNumbers) {
                            this.hero.phoneNumbers.forEach(d => {
                                const g = 
                                 DemoWebApi_DemoData_Client.CreatePhoneNumberFormGroup();
                                g.patchValue(d);
                                this.heroForm.controls.phoneNumbers?.push(g);
                            });
                        }
                    }
                },
                error: error => alert(error)
            });
        });
    }

   ...
}

With Angular Material Components, you easily enable your app to have frontend validations with the constraints implemented in the backend, however, no round trip needed, and no manual crafting of repetitive frontend validation codes needed.

HTML
<div *ngIf="hero">
    <h2>{{hero.name | uppercase}} Details</h2>
    <div><span>id: </span>{{hero.id}}</div>
    <div [formGroup]="heroForm">
        <label for="hero-name">Hero name: </label>
        <mat-form-field>
            <mat-label>Name</mat-label>
            <input matInput id="hero-name" formControlName="name" />
            <mat-error *ngIf="heroForm.controls.name.hasError">
            {{getErrorsText(heroForm.controls.name.errors)}}</mat-error>
        </mat-form-field>
        <input matInput id="hero-dob" type="date" 
               formControlName="dob" placeholder="DOB" />
        <input matInput id="hero-death" type="date" 
         formControlName="death" placeholder="Death" />

        <div>
            <mat-form-field>
                <mat-label>Email</mat-label>
                <input matInput formControlName="emailAddress" placeholder="name@domain" />
                <mat-error *ngIf="heroForm.controls.emailAddress.hasError">
                {{getErrorsText(heroForm.controls.emailAddress.errors)}}</mat-error>
            </mat-form-field>
        </div>

        <div>
            <mat-form-field>
                <mat-label>Web</mat-label>
                <input matInput formControlName="webAddress" />
                <mat-error *ngIf="heroForm.controls.webAddress.hasError">
                {{getErrorsText(heroForm.controls.webAddress.errors)}}</mat-error>
            </mat-form-field>
        </div>

        <div formGroupName="address">
            <mat-form-field>
                <mat-label>Street</mat-label>
                <input matInput formControlName="street1" />
                <mat-error *ngIf="heroForm.controls.address?.controls?.street1?.hasError">
                {{getErrorsText(heroForm.controls.address?.controls?.street1?.errors)}}
                </mat-error>
            </mat-form-field>
            <mat-form-field>
                <mat-label>City</mat-label>
                <input matInput formControlName="city" />
                <mat-error *ngIf="heroForm.controls.address?.controls?.city?.hasError">
                {{getErrorsText(heroForm.controls.address?.controls?.city?.errors)}}
                </mat-error>
            </mat-form-field>
            <mat-form-field>
                <mat-label>State</mat-label>
                <input matInput formControlName="state" />
                <mat-error *ngIf="heroForm.controls.address?.controls?.state?.hasError">
                {{getErrorsText(heroForm.controls.address?.controls?.state?.errors)}}
                </mat-error>
            </mat-form-field>
            <mat-form-field>
                <mat-label>Country</mat-label>
                <input matInput formControlName="country" />
                <mat-error *ngIf="heroForm.controls.address?.controls?.country?.hasError">
                {{getErrorsText(heroForm.controls.address?.controls?.country?.errors)}}
                </mat-error>
            </mat-form-field>
        </div>

        <div *ngFor="let pg of heroForm.controls.phoneNumbers!.controls" [formGroup]="pg">
            <mat-form-field>
                <mat-label>Number</mat-label>
                <input matInput formControlName="fullNumber" /> 
                <button mat-mini-fab color="any" 
                matSuffix (click)="removePhoneNumber(pg)">X</button>
                <mat-error *ngIf="pg.hasError">
                {{getErrorsText(pg.controls.fullNumber.errors)}}</mat-error>
                </mat-form-field>
        </div>
        <div>
            <button mat-raised-button (click)="addPhoneNumber()">Add Phone Number</button>
        </div>
    </div>

    <button mat-raised-button type="button" (click)="goBack()">go back</button>
    <button mat-raised-button type="button" (click)="save()" 
            [disabled]="!allNestedValid(heroForm)">save</button>
</div>

Points of Interest

FormGroup.patchValue and .getRawValue as of Angular 17

FormGroup.patchValue will populate all Form Controls and nested Form Groups except nested Form Arrays. Not too bad, since such codes could compensate:

JavaScript
if (this.hero.phoneNumbers) {
    this.hero.phoneNumbers.forEach(d => {
        const g = DemoWebApi_DemoData_Client.CreatePhoneNumberFormGroup();
        g.patchValue(d);
        this.heroForm.controls.phoneNumbers?.push(g);
    });
}

FormGroup.getRawValue will read all Form Controls, nested Form Groups and nested Form Arrays.

This looks a bit inconsistent. However, I am not sure if being consistent is really significant for application programming with Reactive Forms. Or is it better to let programmers decide whether to populate Form Arrays during application programming? Please leave your comment.

Client Only Data Models

As of Angular 17, for client only data models, you need to craft typed Form Groups manually. And I have made a proposal to the Angular team: "Generate typed FormGroup including validations from interface/model through declarative info of validations". If you like the idea that may be beneficial to your application programming with Reactive Forms, please upvote the issue to make this happen sooner.

History

  • 10th January, 2024: Initial version

License

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


Written By
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.

Comments and Discussions

 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA10-Jan-24 20:04
professionalȘtefan-Mihai MOGA10-Jan-24 20:04 

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.