1. Einleitung

Quinoa ist eine Quarkus Extension, die die Entwicklung und das Bauen von Webanwendungen neben anderen Quarkus Diensten (z. B. REST, GraphQL, …​) erleichtert.

Durch die Quinoa Extension wird in einem Bauprozess sowohl die Quarkus Applikation als auch die Webanwendung gebaut und für die Verwendung bereitgestellt. Alle Build Dateien, die durch den Bauprozess erzeugt werden, werden am Ende in einer Jar Datei komprimiert. Dadurch wird auch das Deployen vereinfacht.

In diesem Tutorial werden ein Quarkus REST Backend und ein Angular Frontend mit Quinoa erstellt.

2. Quarkus Backend

2.1. Quarkus Projekt erstellen

Das Quarkus Projekt kann mitsamt benötigten Abhängigkeiten über die Maven CLI …​

mvn io.quarkus.platform:quarkus-maven-plugin:3.21.0:create \
  -DprojectGroupId=at.htl \
  -DprojectArtifactId=quinoa-tutorial \
  -Dextensions='quinoa,rest-jackson,hibernate-orm-panache,jdbc-postgresql'

oder die Quarkus CLI …​

quarkus create app at.htl:quinoa-tutorial \
  --extensions='quinoa,rest-jackson,hibernate-orm-panache,jdbc-postgresql'

erstellt werden.

Die Abhängigkeiten können auch direkt in der pom.xml eingetragen werden:

pom.xml
pom.xml
<dependency>
    <groupId>io.quarkiverse.quinoa</groupId>
    <artifactId>quarkus-quinoa</artifactId>
    <version>2.5.3</version>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-arc</artifactId>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>

2.2. application.properties

Konfigurationen für die Verbindung zur Datenbank und die Quinoa Extension werden in den application.properties eingetragen:

src/main/resources/application.properties
# database configuration #
quarkus.datasource.db-kind=postgresql(1)
quarkus.datasource.username=app
quarkus.datasource.password=app
quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/db
quarkus.hibernate-orm.database.generation=drop-and-create

# quinoa configuration #
quarkus.quinoa.ui-root-path=quinoa(2)
quarkus.quinoa.enable-spa-routing=true(3)

# path prefix for rest resources #
quarkus.rest.path=/api
1 In diesem Beispiel wird eine postgresql Datenbank verwendet. Die Scripts für die Erstellung der postgresql Datenbank befinden sich hier.
2 Root Pfad für das Hosten der Webanwendung
3 Wenn diese Einstellung aktiviert ist, werden alle Anfragen an tiefere Routen einer SinglePageApplication wie z. B. http://localhost:8080/quinoa/mypage zur Startseite (index.html) umgeleitet. Anschließend übernimmt der Client Router der SPA die Navigation. Wenn die Einstellung deaktiviert wäre, würde der Quarkus Server versuchen eine entsprechende Datei auf dem Server zu finden. Da diese Datei jedoch nicht existiert, weil das Routing clientseitig erfolgt, führt dies zu einem 404 - Not Found.

Eine vollständige Auflistung und Beschreibung aller Quinoa Konfigurationen befindet sich auf den offiziellen Docs.

2.3. Web API

Für die Kommunikation zwischen Backend und Frontend wird ein simples Datenmodell und eine WebAPI bereitgestellt. Der Einfachheit halber werden Entität, Repository und Resource in je einer Datei zusammengefasst.

Trainer.java
src/main/java/at/htl/features/Trainer.java
package at.htl.features;

import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.*;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;

@Entity
class Trainer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    public Long id;
    @Column(nullable = false)
    public String name;
    @Column(name = "is_male", nullable = false)
    public boolean isMale;
}

@ApplicationScoped
class TrainerRepository implements PanacheRepository<Trainer> {}

@Path("trainers")
class TrainerResource {
    @Inject
    TrainerRepository trainerRepository;

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Response getAllTrainers() {
        return Response.ok(trainerRepository.listAll()).build();
    }

    @GET
    @Path("{trainer-id}")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getTrainerById(@PathParam("trainer-id") Long trainerId) {
        return Response.ok(trainerRepository.findById(trainerId)).build();
    }

    @POST
    @Produces(MediaType.APPLICATION_JSON)
    @Consumes(MediaType.APPLICATION_JSON)
    @Transactional
    public Response createTrainer(Trainer trainer, @Context UriInfo uriInfo) {
        trainer.id = null;
        trainerRepository.persist(trainer);
        UriBuilder builder = uriInfo
                .getAbsolutePathBuilder()
                .path(trainer.id.toString());
        return Response.created(builder.build()).build();
    }

    @DELETE
    @Path("{trainer-id}")
    @Produces(MediaType.APPLICATION_JSON)
    @Transactional
    public Response deleteTrainer(@PathParam("trainer-id") Long trainerId) {
        trainerRepository.deleteById(trainerId);
        return Response.status(Response.Status.NO_CONTENT).build();
    }
}
Pokemon.java
src/main/java/at/htl/features/Pokemon.java
package at.htl.features;

import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.*;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;

@Entity
class Pokemon {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    public Long id;
    @Column(nullable = false, unique = true)
    public String name;
    @Column(name = "first_type", nullable = false)
    @Enumerated(EnumType.STRING)
    public PokemonType firstType;
    @Column(name = "second_type")
    @Enumerated(EnumType.STRING)
    public PokemonType secondType;
    @ManyToOne(cascade = {
            CascadeType.PERSIST,
            CascadeType.MERGE,
            CascadeType.DETACH,
            CascadeType.REFRESH
    })
    @JoinColumn(name = "trainer_id")
    public Trainer trainer;
}

@ApplicationScoped
class PokemonRepository implements PanacheRepository<Pokemon> {}

@Path("pokemon")
class PokemonResource {
    @Inject
    PokemonRepository pokemonRepository;

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Response getAllPokemon() {
        return Response.ok(pokemonRepository.listAll()).build();
    }

    @GET
    @Path("type/{type}")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getAllPokemonByType(@PathParam("type") PokemonType type) {
        return Response.ok(pokemonRepository.list("firstType = ?1 or secondType = ?1", type)).build();
    }

    @GET
    @Path("{pokemon-id}")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getPokemonById(@PathParam("pokemon-id") Long pokemonId) {
        return Response.ok(pokemonRepository.findById(pokemonId)).build();
    }

    @POST
    @Produces(MediaType.APPLICATION_JSON)
    @Consumes(MediaType.APPLICATION_JSON)
    @Transactional
    public Response createPokemon(Pokemon pokemon, @Context UriInfo uriInfo) {
        pokemon.id = null;
        pokemonRepository.persist(pokemon);
        UriBuilder builder = uriInfo
                .getAbsolutePathBuilder()
                .path(pokemon.id.toString());
        return Response.created(builder.build()).build();
    }

    @DELETE
    @Path("{pokemon-id}")
    @Produces(MediaType.APPLICATION_JSON)
    @Transactional
    public Response deletePokemon(@PathParam("pokemon-id") Long pokemonId) {
        pokemonRepository.deleteById(pokemonId);
        return Response.status(Response.Status.NO_CONTENT).build();
    }
}

enum PokemonType {
    NORMAL,
    FIRE,
    WATER,
    GRASS,
    ELECTRIC,
    ICE,
    FIGHTING,
    POISON,
    GROUND,
    FLYING,
    PSYCHIC,
    BUG,
    ROCK,
    GHOST,
    DARK,
    DRAGON,
    STEEL,
    FAIRY,
    NONE
}

2.3.1. Beispieldaten

src/main/resources/import.sql
insert into trainer (name, is_male)
    values ('Jens', true);
insert into trainer (name, is_male)
    values ('Jessica', false);
insert into pokemon (name, first_type, second_type, trainer_id)
    values ('Pikachu', 'ELECTRIC', 'NONE', 1);
insert into pokemon (name, first_type, second_type, trainer_id)
    values ('Bisasam', 'GRASS', 'POISON', 1);
insert into pokemon (name, first_type, second_type, trainer_id)
    values ('Gengar', 'GHOST', 'POISON', null);

2.4. Testen

Die Quarkus Applikation kann nun durch …​

./mvnw clean quarkus:dev

oder …​

quarkus dev --clean

gestartet werden.

Die Ressourcen befinden sich auf dem Pfad http://localhost:8080/api/. Auf dem in den application.properties angegebenen UI Pfad http://localhost:8080/quinoa/ kann eine Beispielwebanwendung gefunden werden. Diese wird nun mit einer Angular Applikation ersetzt.

3. Angular Frontend

Die package.json Datei der Webanwendung muss in dem Ordner src/main/webui liegen.

tree structure src
Figure 2. Baumstruktur des src Ordners

Durch die package.json Datei kann Quinoa viele Web Frameworks erkennen und automatisch Konfigurationen vornehmen. Mehr dazu hier.

3.1. Angular Projekt erstellen

Zuerst wird der Inhalt des src/main/webui Ordners geleert.

rm -r src/main/webui/*

Um die Angular CLI nicht global installieren zu müssen, kann folgender Befehl im root des Projekts für die Erstellung der Angular Applikation verwendet werden:

npx -p @angular/cli ng new quinoa-tutorial-frontend --directory src/main/webui --skip-git

Die Idee stammt aus diesem Stack Overflow Beitrag.

Erklärung des Befehls
  1. npx

    • npx ist ein Tool, das mit npm (seit Version 5.2) geliefert wird.

    • Es ermöglicht die Ausführung von npm Paketen, ohne sie global zu installieren.

  2. -p @angular/cli

    • Das -p steht für --package und gibt das Paket an, das npx verwendet werden soll.

    • @angular/cli ist das Paket für die Angular CLI.

    • Dieser Parameter sorgt dafür, dass das Angular CLI Paket heruntergeladen und direkt verwendet wird, ohne es dauerhaft auf deinem System zu installieren.

  3. ng new quinoa-tutorial-frontend

    • ng ist der Befehl für die Angular CLI.

    • new erstellt ein neues Angular Projekt.

    • quinoa-tutorial-frontend ist der Name des Projekts.

  4. --directory src/main/webui

    • Die Option --directory gibt an, wohin die Projektdateien erstellt werden sollen.

    • Statt ein Verzeichnis mit dem Namen des Projekts zu erstellen (z. B. quinoa-tutorial-frontend), legt Angular die Dateien in dem angegebenen Ordner src/main/webui an.

  5. --skip-git

    • Die Option --skip-git verhindert, dass ein neues Git Repository im Projektverzeichnis initialisiert wird.

    • Normalerweise erstellt Angular ein Git Repository, aber in diesem Fall wird es übersprungen.

Die base URL, der Angular Applikation muss mit der in den application.properties angegeben Pfad übereinstimmen. Mithilfe der baseHref Option im angular.json kann dies konfiguriert werden.

src/main/webui/angular.json
{
  "projects": {
    "quinoa-tutorial-frontend": {
      "architect": {
        "build": {
          "options": {
            "outputPath": "dist/quinoa-tutorial-frontend",
            "baseHref": "/quinoa/",
          }
        }
      }
    }
  }
}

3.2. Darstellung der Daten

Die Darstellung der Daten der REST Ressourcen in der Angular Applikation, erfolgt wie üblich. Das gesamte Demoprojekt befindet sich auf GitHub. Die benötigten Dateien werden allerdings auch hier bereitgestellt:

model.ts
src/main/webui/src/shared/model/model.ts
export enum PokemonType {
    NORMAL,
    FIRE,
    WATER,
    GRASS,
    ELECTRIC,
    ICE,
    FIGHTING,
    POISON,
    GROUND,
    FLYING,
    PSYCHIC,
    BUG,
    ROCK,
    GHOST,
    DARK,
    DRAGON,
    STEEL,
    FAIRY,
    NONE
}

export interface Trainer {
    id: number;
    name: string;
    isMale: boolean;
}

export interface Pokemon {
    id: number;
    name: string;
    firstType: PokemonType;
    secondType: PokemonType;
    trainer?: Trainer;
}
app.config.ts
src/main/webui/src/app/app.config.ts
import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router';
import {routes} from './app.routes';
import {provideHttpClient} from '@angular/common/http';

export const appConfig: ApplicationConfig = {
    providers: [
        provideZoneChangeDetection({ eventCoalescing: true }),
        provideRouter(routes),
        provideHttpClient()
    ]
};
data.service.ts
src/main/webui/src/shared/services/data.service.ts
import {inject, Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
import {Pokemon, PokemonType, Trainer} from '../model/model';

@Injectable({
    providedIn: 'root'
})
export class DataService {
    private httpClient: HttpClient = inject(HttpClient);
    private readonly BASE_URL: string = 'http://localhost:8080/api';

    constructor() { }

    public getAllPokemon(): Observable<Pokemon[]> {
        return this.httpClient.get<Pokemon[]>(`${this.BASE_URL}/pokemon`);
    }

    public getAllPokemonByType(type: PokemonType): Observable<Pokemon[]> {
        return this.httpClient.get<Pokemon[]>(`${this.BASE_URL}/pokemon/type/${type}`);
    }

    public getAllTrainers(): Observable<Trainer[]> {
        return this.httpClient.get<Trainer[]>(`${this.BASE_URL}/trainers`);
    }
}
app.component.ts
src/main/webui/src/app/app.component.ts
import {Component, inject} from '@angular/core';
import {DataService} from '../shared/services/data.service';
import {Pokemon, Trainer} from '../shared/model/model';

@Component({
    selector: 'app-root',
    imports: [],
    templateUrl: './app.component.html',
    styleUrl: './app.component.css'
})
export class AppComponent {
    private dataService: DataService = inject(DataService);
    protected pokemon: Pokemon[] = [];
    protected trainers: Trainer[] = [];

    constructor() {
        this.dataService.getAllPokemon().subscribe(p => {
            this.pokemon = p;
        });

        this.dataService.getAllTrainers().subscribe(t => {
            this.trainers = t;
        });
    }
}
app.component.html
src/main/webui/src/app/app.component.html
<h1>Pokemon</h1>

<table>
    <tr>
        <th>Name</th>
        <th>First type</th>
        <th>Second type</th>
        <th>Trainer</th>
    </tr>

    @for (p of pokemon; track p) {
        <tr>
            <td>{{p.name}}</td>
            <td>{{p.firstType}}</td>
            <td>{{p.secondType}}</td>
            <td>{{p.trainer?.name ?? 'none'}}</td>
        </tr>
    }
</table>

<h1>Trainers</h1>

<table>
    <tr>
        <th>Name</th>
        <th>Sex</th>
    </tr>

    @for (t of trainers; track t) {
        <tr>
            <td>{{t.name}}</td>
            <td>{{t.isMale ? 'Male' : 'Female'}}</td>
        </tr>
    }
</table>
styles.css
src/main/webui/src/styles.css
table, th, td {
    border: 1px solid;
}

table {
    width: 50%;
}

3.3. Testen

Das Starten von Quarkus wird hier erklärt. Nun werden auf http://localhost:8080/quinoa/ die auf http://localhost:8080/api/ verfügbaren Pokemon und Trainer angezeigt.

4. Fazit

Mithilfe der Quinoa Extension wurde somit ein Quarkus Backend und ein Angular Frontend im selben Projekt entwickelt. Beim Starten übernimmt Quinoa nicht nur das Bauen der Quarkus App, sondern auch der Webanwendung. Das Framework der Webanwendung kann dabei frei gewählt werden.

5. Quellen