Angular frontend + Spring Boot backend


Dzisiejszy wpis bazuje na artykule dostępnym pod adresem: https://developer.okta.com/blog/2017/12/04/basic-crud-angular-and-spring-boot autorstwa Matt Raible:

Angular to framework oparty o język JavaScript zaprojektowany przez firmę google który w ostatnich latach zyskał ogromne uznanie wśród developerów warstwy frontendu aplikacji. Niewątpliwie ogromną zaletą tej biblioteki jest jej minimalizm, oznacza to, że biblioteka potrafi zlokalizować zmiany w modelu i wprowadzić adekwatne zmiany w kodzie HTML. Narzucenie wzorca MVC umożliwia łatwe testowanie aplikacji a wieloplatformowość pozwala tworzyć aplikację webowe, mobilne czy desktopowe. W tym artykule zaprojektujemy aplikację – REST API używając do tego Spring Boota która będzie pełnić rolę back-endu. Ta aplikacja będzie dostarczać dane do warstwy frontendowej napisanej właśnie z użyciem Angulara. Do dzieła!

Na początek należy zarejestrować się na stronie https://www.okta.com. Pozwoli to na zabezpieczenie naszej aplikacji w modelu SSO (z ang. Single signon). Tworzymy nowy projekt Spring Boot – niezbędne zależności – plik pom.xml:

Spring Boot API

<dependencies>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-actuator</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-data-jpa</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-data-rest</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-web</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-security</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.security.oauth.boot</groupId>
		<artifactId>spring-security-oauth2-autoconfigure</artifactId>
		<version>2.0.0.RELEASE</version>
	</dependency>
	<dependency>
		<groupId>com.h2database</groupId>
		<artifactId>h2</artifactId>
		<scope>runtime</scope>
	</dependency>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<optional>true</optional>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-test</artifactId>
		<scope>test</scope>
	</dependency>
</dependencies>

Model danych – API dostarczać będzie informacji o dostępnych samochodach:

@Entity
public class Car {

    @Id
    @GeneratedValue
    private Long id;

    private @NonNull String name;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

Repozytorium JPA:

@RepositoryRestResource
@CrossOrigin(origins = "http://localhost:4200")
    interface CarRepository extends JpaRepository<Car, Long> {
}

Jako, że aplikacja dostarczająca REST API będzie dostępna na innym porcie niż aplikacja konsumująca REST API z użyciem żądań AJAX to potrzebny jest mechanizm umożliwiający współdzielenie zasobów pomiędzy serwerami znajdującymi się w różnych domenach. Adnotacja @CrossOrigin to adnotacja pozwalająca na dostęp do repozytorium aplikacji Spring Boot dla aplikacji frontendowej w ramach lokalnego serwera.

RestController:

@RestController
class CoolCarController {
    private CarRepository repository;

    public CoolCarController(CarRepository repository) {
        this.repository = repository;
    }

    @GetMapping("/cool-cars")
    @CrossOrigin(origins = "http://localhost:4200")
    public Collection<Car> coolCars() {
        return repository.findAll().stream()
                .filter(this::isCool)
                .collect(Collectors.toList());
    }

    private boolean isCool(Car car) {
        return !car.getName().equals("AMC Gremlin") &&
                !car.getName().equals("Triumph Stag") &&
                !car.getName().equals("Ford Pinto") &&
                !car.getName().equals("Yugo GV");
    }
}

Klasa uruchamiająca aplikację:

@EnableResourceServer
@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @Bean
    ApplicationRunner init(CarRepository repository) {
        return args -> {
            Stream.of("Ferrari", "Jaguar", "Porsche", "Lamborghini", "Bugatti",
                    "AMC Gremlin", "Triumph Stag", "Ford Pinto", "Yugo GV").forEach(name -> {
                Car car = new Car();
                car.setName(name);
                repository.save(car);
            });
            repository.findAll().forEach(System.out::println);
        };
    }

    @Bean
    @SuppressWarnings("unchecked")
    public FilterRegistrationBean simpleCorsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.setAllowedOrigins(Collections.singletonList("http://localhost:4200"));
        config.setAllowedMethods(Collections.singletonList("*"));
        config.setAllowedHeaders(Collections.singletonList("*"));
        source.registerCorsConfiguration("/**", config);
        FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(source));
        bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
        return bean;
    }
}

Adnotacja @EnableResourceServer włącza serwer autoryzacji, jest to jedna z dwóch składowych aplikacji typu OAuth2. Adnotacja ta włączając serwer autoryzacji odpowiada za autoryzację użytkowników czyli uwierzytelnianie – weryfikacja loginu i hasła.

Plik application.yml – należy uzupełnić client-id o wygenerowaną w serwisie unikalną wartość:

security:
    oauth2:
        client:
            access-token-uri: https://dev-125577.oktapreview.com/oauth2/default/v1/token
            user-authorization-uri: https://dev-125577.oktapreview.com/oauth2/default/v1/authorize
            client-id: 
            scope: openid profile email
        resource:
            user-info-uri: https://dev-125577.oktapreview.com/oauth2/default/v1/userinfo
            token-info-uri: https://dev-125577.oktapreview.com/oauth2/default/v1/introspect
            prefer-token-info: false

Angular Client

Jeśli pracujemy w systemie Windows – ściągamy paczkę Node.js ze strony https://nodejs.org/en/download/ Następnie instalujemy narzędzie Angular CLI używając przy tym (menadżera pakietów – npm) wydając z poziomu terminala (polecam to zrobić za pomocą GitBasha) polecenie:

npm install -g @angular/cli

należy pamiętać aby skonfigurować zmienną środowiskową:

NODE_HOME która wskazuje na katalog gdzie znajduje się rozpakowana paczka z Node.js.

Angular CLI to narzędzie do szybkiego i łatwego i efektywnego generowania projektu z użyciem biblioteki Angular. Tworzymy zatem nowy projekt:

ng new client

sprawdzamy czy wszystko jest prawidłowo dostępne wyświetlając wersje narzędzi:

– wersja menadżera pakietów – npm:

npm - version

– wersja Angular CLI:

ng version

– wersja Node.js:

node -v

Przechodzimy do folderu projektu – ./client i instalujemy grafikę – Angular Material:

cd client
npm install --save-exact @angular/material@5.2.4 @angular/cdk@5.2.4

tworzymy usługę:

ng g s car

powstałe pliki – car.service.spec.ts oraz car.service.ts kopiujemy do katalogu ./src/app/shared/car. Katalog ten musi być wcześniej samodzielnie utworzony np poleceniem mkdir.

zawartość pliku car.service.spec.ts:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class CarService {

  public API     = '//localhost:8888';
  public CAR_API = this.API + '/cars';

  constructor(private http: HttpClient) {
  }

  getAll(): Observable<any> {
    return this.http.get(this.API + '/cool-cars');
  }

  get(id: string) {
    return this.http.get(this.CAR_API + '/' + id);
  }

  save(car: any): Observable<any> {
    let result: Observable<Object>;
    if (car['href']) {
      result = this.http.put(car.href, car);
    } else {
      result = this.http.post(this.CAR_API, car);
    }
    return result;
  }

  remove(href: string) {
    return this.http.delete(href);
  }
}

@Injectable() oznacza, że zależności zostaną prawidłowo zainicjalizowane (wstrzyknięte) przez konstruktor. Konstruktor w Angularze powinien być wykorzystywany do wstrzykiwania zależności. W przeciwnym wypadku wystąpiłby błąd:

Error: Can't resolve all parameters for CarService: (?).

metody getAll() oraz save(car: any) zwracają obiekt typu Observable<any>() z bilioteki rxjs/Observable. Są to obiekty obserwowane czyli emitujące pewne wartości. Reaktywność pozwala na asynchroniczne przekazywanie danych pomiędzy komponentami. Angular wykorzystuje potęgę programowania reaktywnego!

Drugi z plików to testy jednostkowe dla usługi. Konwencja mówi o tym, że dla każdego pliku *.ts powinien istnieć plik .spec.ts. Testy uruchamiane są poleceniem:

ng test

W pliku ./src/app/app.module.ts dodajemy:

import { CarService } from './shared/car/car.service';
import { HttpClientModule } from '@angular/common/http';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule
  ],
  providers: [CarService],
  bootstrap: [AppComponent]
})

Klasa CarService zależy od wartości przekazanej do konstruktora. Aby wstrzyknięcie tej zależności zadziałało prawidłowo niezbędne jest dodanie klasy CarService do tablicy providers:

Tworzymy nowy komponent:

ng g c car-list

Wygenerowane zostaną teraz 4 pliki:

CREATE src/app/car-list/car-list.component.html (23 bytes)
CREATE src/app/car-list/car-list.component.spec.ts (636 bytes)
CREATE src/app/car-list/car-list.component.ts (276 bytes)
CREATE src/app/car-list/car-list.component.css (0 bytes)
UPDATE src/app/app.module.ts (402 bytes)

Plik car-list.component.ts:

import { Component, OnInit } from '@angular/core';
import { Observable        } from 'rxjs/Observable';

import { CarService        } from '../shared/car/car.service';
import { GiphyService      } from '../shared/giphy/giphy.service';

@Component({
  selector    : 'app-car-list',
  templateUrl : './car-list.component.html',
  styleUrls   : ['./car-list.component.css']
})
export class CarListComponent implements OnInit {

  cars: Array<any>;

  constructor( private carService   : CarService,
               private giphyService : GiphyService) {
               }

  ngOnInit() {

    this.carService.getAll().subscribe(data => {

      this.cars = data;

      for (const car of this.cars) {
        this.giphyService.get(car.name).subscribe(url => car.giphyUrl = url);
      }
    });
  }
}

metoda ngOnInit wywoływana jest raz, zaraz po konstruktorze klasy oraz pierwszym wywołaniu zdarzenia ngOnChanges (jeżeli istnieją pola oznaczone dekoratorem @Input()). Metoda subscribe podłącza się do strumienia. Pozwala to na asynchroniczną aktualizację danych o liście samochodów za każdym razem kiedy zostanie dodany do listy nowy samochód bądź zostanie edytowany już istniejący. Dekorator @Injectable nie jest potrzebny w przypadku komponentów – https://blog.ninja-squad.com/2016/12/08/angular-injectable.

Plik car-list.component.html:

<mat-card>
  <mat-card-header>Car List</mat-card-header>
  <mat-card-content>
    <mat-list>
      <!-- The *ngFor directive tells Angular to iterate over a list. -->
      <mat-list-item *ngFor="let car of cars">
        <img mat-list-avatar src="{{car.giphyUrl}}" alt="{{car.name}}">
        <h3 mat-line>
          <a mat-button [routerLink]="['/car-edit', car.id]">{{car.name}}</a>
        </h3>
      </mat-list-item>
    </mat-list>
  </mat-card-content>
  <button mat-fab color="primary" [routerLink]="['/car-add']">add</button>
</mat-card>

Czas na zaimportowanie grafiki – dodajemy do pliku ./src/app/app.module.ts wpis:

import { MatButtonModule, MatCardModule, MatInputModule, MatListModule, MatToolbarModule } from '@angular/material';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

oraz uzupełniamy sekcję import:

BrowserModule,
HttpClientModule,
BrowserAnimationsModule,
MatButtonModule,
MatCardModule,
MatInputModule,
MatListModule,
MatToolbarModule

dodajemy wpis do pliku reprezentującego css ./src/style.css:

@import "~@angular/material/prebuilt-themes/pink-bluegrey.css";
@import 'https://fonts.googleapis.com/icon?family=Material+Icons';

body {
 margin: 0;
 font-family: Roboto, sans-serif;
}

Utwórzmy usługę tak jak poprzednio (NIE komponent a usługę) która pobiera graficzne ikony do nazw elementów dodawanych do kolekcji samochodów:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import 'rxjs/add/operator/map';

@Injectable()
export class GiphyService {

  // public beta key: https://github.com/Giphy/GiphyAPI#public-beta-key
  giphyApi = '//api.giphy.com/v1/gifs/search?api_key=dc6zaTOxFJmzC&limit=1&q=';

  constructor(public http: HttpClient) {
  }

  get(searchTerm) {
    const apiLink = this.giphyApi + searchTerm;
    return this.http.get(apiLink).map((response: any) => {
      if (response.data.length > 0) {
        return response.data[0].images.original.url;
      } else {
        return 'https://media.giphy.com/media/YaOxRsmrv9IeA/giphy.gif'; // dancing cat for 404
      }
    });
  }
}

uzupełniamy plik ./src/app/app.module.ts:

providers: [CarService, GiphyService],

tworzymy nowy komponent do edycji:

ng g c car-edit

Plik ./src/app/car-list/car-edit.component.ts:

import { Component, OnDestroy, OnInit         } from '@angular/core';
import { Subscription                         } from 'rxjs/Subscription';
import { ActivatedRoute, Router, ParamMap     } from '@angular/router';
import { CarService                           } from '../shared/car/car.service';
import { GiphyService                         } from '../shared/giphy/giphy.service';
import { NgForm                               } from '@angular/forms';
import {switchMap, map, catchError, skipWhile } from 'rxjs/operators';
import { Observable                           } from 'rxjs/Rx';
import 'rxjs/add/operator/catch';
import 'rxjs/util/pipe';

@Component({
  selector    : 'app-car-edit',
  templateUrl : './car-edit.component.html',
  styleUrls   : ['./car-edit.component.css']
})
export class CarEditComponent implements OnInit,OnDestroy {

  car: any = {};

  sub: Subscription;

  constructor(private route         : ActivatedRoute,
              private router        : Router,
              private carService    : CarService,
              private giphyService  : GiphyService) {
  }

  ngOnInit() {

  this.sub = this.route.paramMap.

        pipe(

          skipWhile(params => !params),

          switchMap((params: ParamMap) => {
                   return this.carService.get(params.get('id'));
               }),

          map((car: any) => {
              this.car      = car;
              this.car.href = car._links.self.href;
              this.giphyService.get(car.name).subscribe(url => car.giphyUrl = url);
          }),

          catchError((error, caught) => {
              return Observable.throw(error);
           })

        ).subscribe();
  }

  ngOnDestroy() {
    this.sub.unsubscribe();
  }

  gotoList() {
    this.router.navigate(['/car-list']);
  }

  save(form: NgForm) {
    this.carService.save(form).subscribe(result => {
      this.gotoList();
    }, error => console.error(error));
  }

  remove(href) {
    this.carService.remove(href).subscribe(result => {
      this.gotoList();
    }, error => console.error(error));
  }
}

Plik ./src/app/car-edit/car-edit.component.html:

<mat-card>
  <form #carForm="ngForm" (ngSubmit)="save(carForm.value)">
    <mat-card-header>
      <mat-card-title><h2>{{car.name ? 'Edit' : 'Add'}} Car</h2></mat-card-title>
    </mat-card-header>
    <mat-card-content>
      <input type="hidden" name="href" [(ngModel)]="car.href">
      <mat-form-field>
        <input matInput placeholder="Car Name" [(ngModel)]="car.name"
               required name="name" #www>
      </mat-form-field>
    </mat-card-content>
    <mat-card-actions>
      <button mat-raised-button color="primary" type="submit"
              [disabled]="!carForm.form.valid">Save</button>
      <button mat-raised-button color="secondary" (click)="remove(car.href)"
              *ngIf="car.href" type="button">Delete</button>
      <a mat-button routerLink="/car-list">Cancel</a>
    </mat-card-actions>
    <mat-card-footer>
      <div class="giphy">
        <img src="{{car.giphyUrl}}" alt="{{car.name}}">
      </div>
    </mat-card-footer>
  </form>
</mat-card>

uzupełniamy plik ./src/app/app.module.ts o routing oraz o moduł FormsModule:

import { FormsModule } from '@angular/forms';
import { RouterModule, Routes } from '@angular/router';

const appRoutes: Routes = [
  { path: '', redirectTo: '/car-list', pathMatch: 'full' },
  {
    path: 'car-list',
    component: CarListComponent
  },
  {
    path: 'car-add',
    component: CarEditComponent
  },
  {
    path: 'car-edit/:id',
    component: CarEditComponent
  }
];

@NgModule({
  ...
  imports: [
    ...
    FormsModule,
    RouterModule.forRoot(appRoutes)
  ],
  ...
})

Dodanie klasy stylu do pliku ./src/app/car-edit/car-edit.component.css:

.giphy {
  margin: 10px;
}

Modyfikacja pliku ./src/app/app.component.html:

<mat-toolbar color="primary">
  <span>Welcome to {{title}}!</span>
</mat-toolbar>

<router-outlet></router-outlet>

Konfiguracja modelu SSO z użyciem systemu https://www.okta.com:

W katalogu projektu należy wydać komendę:

npm install @okta/okta-angular@1.0.0

W pliku ./src/app/app.module.ts dodajemy wpis:

const config = {
  issuer: 'https://dev-125577.oktapreview.com/oauth2/default/',
  redirectUri: 'http://localhost:4200/implicit/callback',
  clientId: '0oaj7e7dkfx4np1yW0h7'
};

importujemy:

import { OktaCallbackComponent, OktaAuthModule } from '@okta/okta-angular';

oraz dodajemy do @NgModule:

@NgModule({
  ...
  imports: [
    ...
    OktaAuthModule.initAuth(config)
  ],
  ...
})

należy również dodać odpowiedni routing:

const appRoutes: Routes = [
  ...
  {
    path: 'implicit/callback',
    component: OktaCallbackComponent
  }
];

Tworzymy interceptor który będzie miał zadanie dodawać token autoryzacyjny do każdego żądania HTTP:

Plik ./src/app/shared/okta/auth.interceptor.ts:

import { Injectable      } from '@angular/core';
import { Observable      } from 'rxjs/Observable';
import { OktaAuthService } from '@okta/okta-angular';
import { fromPromise     } from 'rxjs/observable/fromPromise';

import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {

  constructor(private oktaAuth: OktaAuthService) {
  }

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return fromPromise(this.handleAccess(request, next));
  }

  private async handleAccess(request: HttpRequest<any>, next: HttpHandler): Promise<HttpEvent<any>> {
    // Only add to known domains since we don't want to send our tokens to just anyone.
    // Also, Giphy's API fails when the request includes a token.
    if (request.urlWithParams.indexOf('localhost') > -1) {
      const accessToken = await this.oktaAuth.getAccessToken();
      request = request.clone({
        setHeaders: {
          Authorization: 'Bearer ' + accessToken
        }
      });
    }
    return next.handle(request).toPromise();
  }
}

Rejestrujemy interceptor w pliku ./app/app.module.ts:

import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { AuthInterceptor } from './shared/okta/auth.interceptor';

@NgModule({
  ...
  providers: [CarService, GiphyService,
    {provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true}],
  ...
})

Modyfikujemy plik app.component.html:

<mat-toolbar color="primary">
  <span>Welcome to {{title}}!</span>
  <span class="toolbar-spacer"></span>
  <button mat-raised-button color="accent" *ngIf="isAuthenticated"
          (click)="oktaAuth.logout()" [routerLink]="['/home']">Logout
  </button>
</mat-toolbar>

<router-outlet></router-outlet>

Dodatkowy css:

Plik ./src/app/app.component.css

.toolbar-spacer {
  flex: 1 1 auto;
}

Modyfikacja pliku ./src/app/app.component.ts – weryfikacja autentykacji użytkownika:

import { Component, OnInit } from '@angular/core';
import { OktaAuthService } from '@okta/okta-angular';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  title = 'app';
  isAuthenticated: boolean;

  constructor(private oktaAuth: OktaAuthService) {
  }

  async ngOnInit() {
    this.isAuthenticated = await this.oktaAuth.isAuthenticated();
    // Subscribe to authentication state changes
    this.oktaAuth.$authenticationState.subscribe(
      (isAuthenticated: boolean)  => this.isAuthenticated = isAuthenticated
    );
  }
}

Ostatnim elementem jest dodanie modułu home:

ng g c home

Modyfikacja pliku ./src/app/app.module.ts:

const appRoutes: Routes = [
  {path: '', redirectTo: '/home', pathMatch: 'full'},
  {
    path: 'home',
    component: HomeComponent
  },
  ...
}

Plik ./src/app/home/home.component.html:

<mat-card>
  <mat-card-content>
    <button mat-raised-button color="accent" *ngIf="!isAuthenticated"
            (click)="oktaAuth.loginRedirect()">Login
    </button>
    <button mat-raised-button color="accent" *ngIf="isAuthenticated"
            [routerLink]="['/car-list']">Car List
    </button>
  </mat-card-content>
</mat-card>

Plik ./src/app/home/home.component.ts:

import { OktaAuthService } from '@okta/okta-angular';

export class HomeComponent implements OnInit {
  isAuthenticated: boolean;

  constructor(private oktaAuth: OktaAuthService) {
  }

  async ngOnInit() {
    this.isAuthenticated = await this.oktaAuth.isAuthenticated();
    // Subscribe to authentication state changes
    this.oktaAuth.$authenticationState.subscribe(
      (isAuthenticated: boolean)  => this.isAuthenticated = isAuthenticated
    );
  }
}

Tstujemy aplikację uruchamiając tryb live development (w trybie produkcyjnym nie stosować!):

ng serve

Kilka screenów – widok strony głównej – home:

widok panelu edycji:

Kod źródłowy do wglądu na GitHub!

Jeśli chcesz uzyskać dostęp do GitHuba na 30 dni i pobrać kod źródłowy wyślij smsa o treśći DOSTEP.EDUSESSION na numer 7943. Tyle wiedzy a koszt to tylko 9 PLN (11.07 PLN z VAT).





Leave a comment

Your email address will not be published.


*