Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / ASP.NET / ASP.NET-Core

Tour of Heroes: Angular, with ASP.NET Core Backend

2.82/5 (3 votes)
18 Nov 2023CPOL3 min read 5K  
A series of articles comparing programmer experiences of Angular, Aurelia, React, Vue, Xamarin and MAUI
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:

  1. A few screens presenting tables and nested data
  2. Data binding
  3. Navigation
  4. CRUD operations over a backend, and optionally through a generated client API
  5. 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:

  1. Computing science
  2. Software engineering
  3. Learning curve
  4. Build size
  5. Runtime performance
  6. 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

  1. Web API on ASP.NET Core 8
  2. Frontend on Angular 17

Demo Repository

Checkout DemoCoreWeb in GitHub, and focus on the following areas:

Core3WebApi

ASP.NET Core Web API

Angular Heroes

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

  1. Core3WebApi.csproj has NuGet packages Fonlow.WebApiClientGenCore and Fonlow.WebApiClientGenCore.NG2 imported.
  2. Add CodeGenController.cs to Core3WebApi.csproj.
  3. Core3WebApi.csproj has CodeGen.json. This is optional, just for the convenience of running some PowerShell script to generate client APIs.
  4. 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

TypeScript
export namespace DemoWebApi_Controllers_Client {

    /**
     * Complex hero type
     */
    export interface Hero {
        id?: number | null;
        name?: string | null;
    }

}
TypeScript
@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 api/Heroes/{id}
     */
    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' });
    }

    /**
     * Get a hero.
     * GET api/Heroes/{id}
     */
    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 });
    }

    /**
     * Get all heroes.
     * GET api/Heroes
     */
    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 api/Heroes
     */
    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:

TypeScript
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

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)

TypeScript
@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

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

TypeScript
@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:

TypeScript
export class HeroDetailComponent implements OnInit {
    hero?: DemoWebApi_Controllers_Client.Hero;
TypeScript
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

TypeScript
const routes: Routes = [
  { path: '', redirectTo: '/dashboard', pathMatch: 'full' },
  { path: 'dashboard', component: DashboardComponent },
  { path: 'detail/:id', component: HeroDetailComponent },
  { path: 'heroes', component: HeroesComponent }
];
TypeScript
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.

TypeScript
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:

  1. 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.
  2. 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.

License

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