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
How to Generate
When running Fonlow.OpenApiClientGen.exe without parameter, you will see the following hints:
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":
{
"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
export interface Pet {
id?: number | null;
category?: Category;
name: string;
photoUrls: Array<string>;
friend?: Pet;
tags?: Array<Tag>;
status?: PetStatus | null;
petType?: string | null;
}
export interface PetFormProperties {
id: FormControl<number | null | undefined>,
name: FormControl<string | null | undefined>,
status: FormControl<PetStatus | null | undefined>,
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),
});
}
export interface Dog extends Pet {
packSize: number;
}
export interface DogFormProperties extends PetFormProperties {
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)]),
});
}
export interface Cat extends Pet {
huntingSkill: CatHuntingSkill;
}
export interface CatFormProperties extends PetFormProperties {
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) {
}
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' });
}
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' });
}
GetPetById(petId: number): Observable<Pet> {
return this.http.get<Pet>(this.baseUri + 'pet/' + petId, {});
}
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):
export interface Hero {
address?: DemoWebApi_DemoData_Client.Address;
death?: Date | null;
dob?: Date | null;
id?: number | null;
name?: string | null;
phoneNumbers?: Array<DemoWebApi_DemoData_Client.PhoneNumber>;
}
export namespace DemoWebApi_DemoData_Client {
export interface Address {
city?: string | null;
country?: string | null;
id?: string | null;
postalCode?: string | null;
state?: string | null;
street1?: string | null;
street2?: string | null;
type?: DemoWebApi_DemoData_Client.AddressType | null;
location?: DemoWebApi_DemoData_Another_Client.MyPoint;
}
export interface PhoneNumber {
fullNumber?: string | null;
phoneType?: DemoWebApi_DemoData_Client.PhoneType | null;
}
export interface HeroFormProperties {
death: FormControl<Date | null | undefined>,
dob: FormControl<Date | null | undefined>,
emailAddress: FormControl<string | null | undefined>,
id: FormControl<number | null | undefined>,
name: FormControl<string | null | undefined>,
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.
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);
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.
<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:
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