从 Swagger / OpenAPI 定义生成 Angular 响应式表单的类型化表单
通过生成的 Angular TypeScript 代码构建客户端数据模型的 Angular 响应式表单。
引言
您正在使用 Angular 构建需要与第三方 Web API 集成的应用程序,而该第三方供应商提供了 OpenAPI 定义文件。
在开发复杂的业务应用程序(SPA、PWA)的重量级 Web 客户端(使用 Angular 2+)时,您倾向于使用 响应式表单 而非 模板驱动表单 来进行数据录入。
本文将介绍如何使用 OpenApiClientGen 来生成数据模型的接口、后端客户端 API 函数,以及 类型化表单 代码,包括验证和对 严格模式 的友好支持。
背景
在使用响应式表单时,截至 Angular 17,您必须手动创建 `FormGroups`、`FormControls` 和 `FormArray` 等,并以逻辑结构重新组合客户端数据模型。每次 Web API 因数据模型更改而升级时,您都必须调整相应的响应式表单代码。
对于复杂的业务应用程序,为了提高生产力、质量和可维护性,并实现持续集成,验证规则应该有一个单一的真实来源。这个单一真实来源应该存在于哪里?
通常,如果需要后端,它就应该在后端。如今,验证规则的定义通常以 Swagger / OpenAPI 定义的形式呈现,类似于 SOAP Web 服务的 WSDL。
能否在一定程度上自动化 `FormGroups` 和验证规则的构建?
当然,这正是许多 Angular 开发者一直在寻找的。您可能已经通过搜索“swagger generate formgroup angular”等关键词找到了一些工具,以下是我找到的一些工具列表:
- https://github.com/jnwltr/swagger-angular-generator
- https://github.com/verizonconnect/ngx-form-generator
- https://npmjs.net.cn/package/angular-formsbuilder-gen
- https://github.com/fiorsaoirse/ngx-openapi-forms
然而,截至 2024 年 1 月的测试,它们都无法真正处理 petstore.json 或 petstore.yaml,尽管有些简单的,如 AddressForms,是可以正常工作的。
很有可能我错过了什么对复杂业务应用程序(复杂的 Swagger / OpenAPI 定义)足够好的工具。如果您找到了,请留下您的评论。
OpenApiClientGen
为 Angular 应用程序开发者提供了哪些额外的有益功能?
- 类型化表单
- 对严格模式友好
- 包含数据模型接口和后端 Web API 的 API 函数
备注
- 如果您正在开发 ASP.NET (Core) Web API,您基本上不需要 Swagger / OpenAPI,因为 WebApiClientGen 可以生成包括类型化表单在内的客户端 API 代码。请查看“使用 ASP.NET Core Web API 生成 Angular 响应式表单的类型化 FormGroup”。
OpenApiClientGen
已 针对超过 1000 个 OpenAPI 定义进行了 Angular 类型化表单的测试。
Using the Code
前往 releases 下载 OpenApiClientGenxxx.zip 并解压到本地文件夹。或者,您也可以从 .NET 7 的源代码进行构建。该存储库还提供了一个 用于构建 macOS 版本的脚本。
必备组件
- .NET 7 或更高版本
如何生成
在不带参数运行 Fonlow.OpenApiClientGen.exe 时,您将看到以下提示:
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>
一个典型的 CodeGen
JSON 文件如下所示:"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"
}
]
}
生成的 TypeScript 代码
从 pet.yaml 生成的 Pet.ts
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' });
}
备注
- 虽然接口很好地支持了 Pet 和 Cat/Dog 之间的继承,但截至 Angular 17,类型化表单本身并不直接支持继承,如 #47091 和 #49374 中所述。因此,代码生成器必须为 `Cat` 和 `Dog` 重复创建 `FormControls`。尽管如此,这比手动重复输入或复制粘贴要好。
- 对于复杂类型(如“
tags?: Array<Tag>
”)的属性,代码生成器不会创建嵌套的 `FormGroup`,具体解释如下。
嵌套的复杂对象或数组
当您学习 Angular 时,您可能已经学习了 Tour of Heroes,因此我将使用扩展示例:HeroesDemo 来进一步说明。
根据整体 UX 设计、业务约束和技术约束,在构建 Angular 响应式表单的应用程序编程中做出相应的设计决策。这就是为什么插件 `NG2FormGroup` 会跳过复杂类型和数组的属性。但是,如果您的设计决策是始终一次性添加和更新一个具有嵌套结构的复杂对象,那么利用生成的代码仍然很容易,如下例所示(HeroesDemo)
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;
}
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()@:%_\\+.~#?&//=]*)')]),
});
}
通过继承和组合,在应用程序代码中,您可以创建一个 `FormGroup`,其中包含扩展的 `Hero` 类型的所有属性。
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)
});
});
}
...
}
借助 Angular Material 组件,您可以轻松地为应用程序启用前端验证,并使用后端实现的约束,无需往返,也无需手动编写重复的前端验证代码。
<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>
关注点
截至 Angular 17 的 FormGroup.patchValue 和 .getRawValue
`FormGroup.patchValue` 将填充所有 Form Controls 和嵌套的 Form Groups,**但不包括**嵌套的 Form Arrays。这不算太糟糕,因为这样的代码可以进行补充。
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` 将读取所有 Form Controls、嵌套的 Form Groups **以及**嵌套的 Form Arrays。
这看起来有些不一致。但是,我不确定一致性在响应式表单的应用程序编程中是否真正重要。或者,让程序员决定是否在应用程序编程中填充 Form Arrays 更好?请留下您的评论。
仅客户端数据模型
截至 Angular 17,对于仅客户端的数据模型,您需要手动创建类型化的 Form Groups。我已经向 Angular 团队提交了一项提案:“从接口/模型通过声明式的验证信息生成包含验证的类型化 FormGroup”。如果您喜欢这个想法,并且认为它可能对您的响应式表单应用程序编程有益,请**点赞**该问题,以便更快地实现。
历史
- 2024 年 1 月 10 日:初始版本