Angular ReactiveForms, despite their problems, are a powerful tool for encoding form validation rules reactively.
Single Source of Truth for Validation Rules
Your backend code should be the single source of truth for validation rules. Of course we should validate input in the UI for a better user experience.
Likely we are either implementing the same rules from the same spec, or copying what has been implemented in the API, or layers behind that. We should be asking ourselves, Where should the single source of truth for validation rules live? It probably shouldn't be in the Angular app. We can eliminate this manual duplication by generating Angular ReactiveForms from OpenAPI/Swagger specs, rather than hand coding them.
This eliminates bugs where the validation rules between the UI and API fall out of sync -- or copied incorrectly from the backend to the frontend. When new validation rules change, just rerun the command to generate the reactive form from the updated OpenAPI spec.
This works very well in conjunction with Rest API proxies generated using the openapi-generator.
Prerequisite
If your projects do not meet following prerequisite, you can still use the Quick Start.
In order for this to work with your Rest API you will need to have your backend provide a well formed Swagger (OpenAPI 2) or OpenAPI 3 spec that includes model-metadata for validation expressed as type
, format
, pattern
, minLength
, maxLength
, etc. Frameworks such as SwashbuckleCore and SpringFox (and many others) do this for you based on metadata provided using attributes or annotations.
Quick Start
This quick start uses a hosted swagger spec, so if you can still go through it whether or not your API exposes the required model-metadata.
First, let's create a new app.
npm i -g @angular/cli
ng n example
cd example
Second, install the generator into your Angular project as a dev dependancy.
npm install --save-dev @verizonconnect/ngx-form-generator
Third, update your package.json
scripts
to include a script to generate the form. This way when the API changes we can easily rerun this script to regenerate the form.
{
. . .
"scripts": {
. . .
"generate:address-form": "ngx-form-generator -i https://raw.githubusercontent.com/verizonconnect/ngx-form-generator/master/demo/swagger.json -o src/app/"
},
. . .
}
Now run the script.
npm run generate:address-form
Within your src/app
you will now have a new generated file based on the name of the OpenAPI title
property, in this case myApi.ts
. You can change this using the -f
argument and provide the filename you like.
We can now import the form into a component and expose it to the template.
import { Component } from '@angular/core';
import { addressModelForm } from './myApi'; // <- import the form
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
addressForm = addressModelForm; // <- expose form to template
}
We will add Angular Material to our project to provide styles for our form in this example. Of course there is no dependency on any CSS or component library.
ng add @angular/material
In the module lets import ReactiveFormModule
, MatFormFieldModule
and MatInputModule
.
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ReactiveFormsModule } from '@angular/forms'; // <- ESM imports start
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
BrowserAnimationsModule,
ReactiveFormsModule, // <- NgModule imports start
MatFormFieldModule,
MatInputModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
We will build the form. For demonstration purposes only need the first field.
<form [formGroup]="addressForm">
<mat-form-field>
<mat-label>First Name</mat-label>
<input matInput formControlName="firstName">
<mat-error *ngIf="addressForm.controls.firstName.invalid">This field is invalid</mat-error>
</mat-form-field>
</form>
We can now run this with ng serve
and see our single field form.
An invalid name can be entered into the field and we will see this validated based on the validation rules from exposed to the Angular application through the OpenAPI spec.
Here we can enter a valid name and see that the validation updates.
If this were a real RestAPI we could add the rest of our form fields and go.
Isolate Generated Code into a Library
We can improve this by putting the generated form into its own library within the Angular workspace. The benefits of this are:
- Clear separation of boundaries between the generated and crafted code. One of the power-dynamics of generating API proxies and forms is being able to safely regenerate them. This will help prevent a team-member from manually modifying the generated form.
- No need to recompile the form during local development. The form project will only need to be recompiled when after it has been regenerated.
- We can add this generation process as part of the CICD build process.
Create a new library
ng g lib address-form
We can now remove the scaffolded component, service and module from the lib.
rm -fr projects/address-form/src/lib/*
We are placing only generated code into this library. We do not want to create unit tests for generated code. Tests should live with the code generator itself. So lets get rid of the unit test support files.
rm projects/address-form/karma.conf.js
rm projects/address-form/tsconfig.spec.json
rm projects/address-form/src/test.ts
We don't need to lint generated code, so lets get rid of tslint.json
rm projects/address-form/tslint.json
We need to remove the references to the test and lint files in the workspace angular.json
. Open angular.json
and find "address-form": {
property. Remove the "test": {
and "lint": {
sections.
It should then look something like this.
"address-form": {
"projectType": "library",
"root": "projects/address-form",
"sourceRoot": "projects/address-form/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-ng-packagr:build",
"options": {
"tsConfig": "projects/address-form/tsconfig.lib.json",
"project": "projects/address-form/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/address-form/tsconfig.lib.prod.json"
}
}
}
}
}
In package.json
we need to add a script to build our lib as well as update the path where we generate the form.
"generate:address-form": "ngx-form-generator -i https://raw.githubusercontent.com/verizonconnect/ngx-form-generator/master/demo/swagger.json -o projects/address-form/src/lib",
"build:libs": "ng build address-form"
Now lets generate the form into the lib.
npm run generate:address-form
Now replace all of the exports in proects/address-form/src/public-api.ts
with:
export * from './lib/myApi';
Build the lib with:
npm run build:libs
We can remove the old generated myApi.ts
rm src/app/myApi.ts
Last, update the import of the form
import { addressModelForm } from 'address-form';
Conclusion
Generating forms will allow you to keep your validation rules in sync with the backend. Like generating proxies, this will greatly reduce integration bugs that occur by trying to manually implement backend to UI data contracts.
Top comments (1)
Great solution!