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
<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:
# 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
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
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
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.

src
OrdnersDurch 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
-
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.
-
-
-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.
-
-
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.
-
-
--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 Ordnersrc/main/webui
an.
-
-
--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.
{
"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
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
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
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
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
<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
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.