Tour of Heroes on Angular, talking to a real backend through generated client API
Background
"Tour of Heroes" is the official tutorial app of Angular 2+. The app contains some functional features and technical features which are common when building a real-world business application:
- A few screens presenting tables and nested data
- Data binding
- Navigation
- CRUD operations over a backend, and optionally through a generated client API
- Unit testing and integration testing
In this series of articles, I will demonstrate the programmer experiences of various frontend development platforms when building the same functional features: "Tour of Heroes", a fat client talking to a backend.
The frontend apps on Angular, Aurelia, React, Vue, Xamarin and MAUI are talking to the same ASP.NET (Core) backend through generated client APIs. To find the other articles in the same series, please search "Heroes" in my articles. And at the end of the series, some technical factors of programmer experiences will be discussed:
- Computing science
- Software engineering
- Learning curve
- Build size
- Runtime performance
- Debugging
Choosing a development platform involves a lot of non-technical factors which won't be discussed in this series.
References:
Introduction
This article is focused on Angular.
Development Platforms
- Web API on ASP.NET Core 8
- Frontend on Angular 17
Demo Repository
Checkout DemoCoreWeb in GitHub, and focus on the following areas:
ASP.NET Core Web API
This is a refined version of the official tutorial demo of Angular "Tour of Heroes". Rather than using a mock service in memory, this one talks to a real backend of ASP.NET Core Web API through generated client API.
Remarks
DemoCoreWeb was established for testing NuGet packages of WebApiClientGen and demonstrating how to use the libraries in real world projects.
Using the Code
Prerequisites
- Core3WebApi.csproj has NuGet packages Fonlow.WebApiClientGenCore and Fonlow.WebApiClientGenCore.NG2 imported.
- Add CodeGenController.cs to Core3WebApi.csproj.
- Core3WebApi.csproj has CodeGen.json. This is optional, just for the convenience of running some PowerShell script to generate client APIs.
- CreateWebApiClientApi3.ps1. This is optional. This script will launch the Web API on DotNet Kestrel web server and post the data in CodeGen.json.
Remarks
Depending on your CI/CD process, you may adjust item 3 and 4 above. For more details, please check:
Generate Client API
Run CreateWebApiClientApi3.ps1, the generated codes will be written to WebApiCoreNg2ClientAuto.ts.
Data Models and API Functions
export namespace DemoWebApi_Controllers_Client {
export interface Hero {
id?: number | null;
name?: string | null;
}
}
@Injectable()
export class Heroes {
constructor(@Inject('baseUri') private baseUri:
string = window.location.protocol + '//' +
window.location.hostname + (window.location.port ? ':' +
window.location.port : '') + '/', private http: HttpClient) {
}
delete(id: number | null, headersHandler?: () => HttpHeaders):
Observable<HttpResponse<string>> {
return this.http.delete(this.baseUri + 'api/Heroes/' + id,
{ headers: headersHandler ? headersHandler() : undefined,
observe: 'response', responseType: 'text' });
}
getHero(id: number | null, headersHandler?: () =>
HttpHeaders): Observable<DemoWebApi_Controllers_Client.Hero> {
return this.http.get<DemoWebApi_Controllers_Client.Hero>
(this.baseUri + 'api/Heroes/' + id,
{ headers: headersHandler ? headersHandler() : undefined });
}
getHeros(headersHandler?: () => HttpHeaders):
Observable<Array<DemoWebApi_Controllers_Client.Hero>> {
return this.http.get<Array<DemoWebApi_Controllers_Client.Hero>>
(this.baseUri + 'api/Heroes', { headers: headersHandler ?
headersHandler() : undefined });
}
post(name: string | null, headersHandler?: () => HttpHeaders):
Observable<DemoWebApi_Controllers_Client.Hero> {
return this.http.post<DemoWebApi_Controllers_Client.Hero>
(this.baseUri + 'api/Heroes', JSON.stringify(name),
{ headers: headersHandler ? headersHandler().append('Content-Type',
'application/json;charset=UTF-8') : new HttpHeaders
({ 'Content-Type': 'application/json;charset=UTF-8' }) });
}
While there could be multiple ways of utilizing the generated API functions, the orthodox way is to inject through the recommended Dependency Injection mechanism of respective development platform. For example:
export function clientFactory(http: HttpClient) {
if (SiteConfigConstants.apiBaseuri) {
console.debug('apiBaseuri:' + SiteConfigConstants.apiBaseuri)
return new namespaces.DemoWebApi_Controllers_Client.Heroes
(SiteConfigConstants.apiBaseuri, http);
}
const _baseUri = location.protocol + '//' + location.hostname +
(location.port ? ':' + location.port : '') + '/';
const webApiUrl = _baseUri + 'webapi/';
console.debug('webApiUrl: ' + webApiUrl);
return new namespaces.DemoWebApi_Controllers_Client.Heroes(webApiUrl, http);
}
@NgModule({
imports: [
...
AppRoutingModule,
HttpClientModule,
...
],
declarations: [
AppComponent,
DashboardComponent,
HeroesComponent,
HeroDetailComponent,
MessagesComponent,
HeroSearchComponent
],
providers: [
{
provide: namespaces.DemoWebApi_Controllers_Client.Heroes,
useFactory: clientFactory,
deps: [HttpClient],
},
...
]
})
Views
Editing
hero-detail.component.html
<div *ngIf="hero">
<h2>{{hero.name | uppercase}} Details</h2>
<div><span>id: </span>{{hero.id}}</div>
<div>
<label for="hero-name">Hero name: </label>
<input id="hero-name" [(ngModel)]="hero.name" placeholder="Hero name"/>
</div>
<button type="button" (click)="goBack()">go back</button>
<button type="button" (click)="save()">save</button>
</div>
hero-detail.component.ts (Codes behind)
@Component({
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html',
styleUrls: ['./hero-detail.component.css']
})
export class HeroDetailComponent implements OnInit {
hero?: DemoWebApi_Controllers_Client.Hero;
constructor(
private heroService: DemoWebApi_Controllers_Client.Heroes,
private route: ActivatedRoute,
private location: Location
) {
}
ngOnInit(): void {
this.route.params.forEach((params: Params) => {
const id = +params['id'];
this.heroService.getHero(id).subscribe({
next: hero => {
if (hero) {
this.hero = hero;
}
},
error: error => alert(error)
});
});
}
save(): void {
if (this.hero) {
this.heroService.put(this.hero!).subscribe(
{
next: d => {
console.debug('response: ' + JSON.stringify(d));
},
error: error => alert(error)
}
);
}
}
goBack(): void {
this.location.back();
}
}
Heroes List
heroes.component.html
<h2>My Heroes</h2>
<div>
<label for="new-hero">Hero name: </label>
<input id="new-hero" #heroName />
<button type="button" class="add-button" (click)="add(heroName.value); heroName.value=''">
Add hero
</button>
</div>
<ul class="heroes">
<li *ngFor="let hero of heroes">
<a routerLink="/detail/{{hero.id}}">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</a>
<button type="button" class="delete" title="delete hero"
(click)="delete(hero)">x</button>
</li>
</ul>
heroes.component.ts
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
export class HeroesComponent implements OnInit {
heroes?: namespaces.DemoWebApi_Controllers_Client.Hero[];
selectedHero?: namespaces.DemoWebApi_Controllers_Client.Hero;
constructor(
private heroService: namespaces.DemoWebApi_Controllers_Client.Heroes,
private router: Router) { }
getHeroes(): void {
this.heroService.getHeros().subscribe(
heroes => {
this.heroes = heroes;
}
);
}
add(name: string): void {
name = name.trim();
if (!name) { return; }
this.heroService.post(name).subscribe(
hero => {
this.heroes?.push(hero);
this.selectedHero = undefined;
});
}
delete(hero: namespaces.DemoWebApi_Controllers_Client.Hero): void {
this.heroService.delete(hero.id!).subscribe(
() => {
this.heroes = this.heroes?.filter(h => h !== hero);
if (this.selectedHero === hero) { this.selectedHero = undefined; }
});
}
ngOnInit(): void {
this.getHeroes();
}
onSelect(hero: namespaces.DemoWebApi_Controllers_Client.Hero): void {
this.selectedHero = hero;
}
gotoDetail(): void {
this.router.navigate(['/detail', this.selectedHero?.id]);
}
}
View Models
In an Angular component, a public data field or a function is a view model, being monitored by Angular runtime for change detection. For example:
export class HeroDetailComponent implements OnInit {
hero?: DemoWebApi_Controllers_Client.Hero;
export class HeroesComponent implements OnInit {
heroes?: namespaces.DemoWebApi_Controllers_Client.Hero[];
selectedHero?: namespaces.DemoWebApi_Controllers_Client.Hero;
Hints
Angular Reactive Forms essentially provides advanced view model in addition to data models becoming view models.
Routing
Angular provides advanced routing.
Symbolic Routing Globally or Within a Module
const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'detail/:id', component: HeroDetailComponent },
{ path: 'heroes', component: HeroesComponent }
];
gotoDetail(): void {
this.router.navigate(['/detail', this.selectedHero?.id]);
}
Integration Testing
Since the frontend of "Tour of Heroes" is a fat client, significant portion of the integration testing is against the backend.
describe('Heroes API', () => {
let service: DemoWebApi_Controllers_Client.Heroes;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [HttpClientModule],
providers: [
{
provide: DemoWebApi_Controllers_Client.Heroes,
useFactory: heroesClientFactory,
deps: [HttpClient],
},
]
});
service = TestBed.get(DemoWebApi_Controllers_Client.Heroes);
}));
it('getAll', (done) => {
service.getHeros().subscribe(
data => {
expect(data!.length).toBeGreaterThan(0);
done();
},
error => {
fail(errorResponseToString(error));
done();
}
);
}
);
it('getHero', (done) => {
service.getHero(9999).subscribe(
data => {
expect(data).toBeNull();
done();
},
error => {
fail(errorResponseToString(error));
done();
}
);
}
);
it('Add', (done) => {
service.post('somebody').subscribe(
data => {
expect(data!.name).toBe('somebody');
done();
},
error => {
fail(errorResponseToString(error));
done();
}
);
}
);
Points of Interest
DemoCoreWeb in GitHub is initially created for testing released packages of WebApiClient
, it also serves the following purposes well:
- A demo not too simple and not too complex for various development platforms. After you learn one, it should be easy for you to learn the others, based on the same business functionality.
- Demonstrating programmer experience of using various development platforms, to give you some ideas when choosing a development platform for your next project, based on your business content and context.
The backend provides multiple sets of Web API, while "Tour of Heroes" consumes only those exposed in HeroesController
. In the real world, a backend may serve multiple frontend apps, while a front end app may talk to multiple backends.