SCM-Manager – Wie starte ich mein erstes Plugin?
Im vorherigen Beitrag haben wir erklärt, warum und wie Plugins verwendet werden können, um die Grundfunktionalität von SCM-Manager zu erweitern, wie ihre grundlegende Architektur aussieht und wie Sie von Ihnen entwickelte Plugins mit der Community teilen können. In diesem Beitrag zeigen wir Ihnen, wie Sie ein Plugin von Grund auf erstellen können, indem Sie das Backend für ein neues Plugin entwickeln.
Unsere Plugin-Idee
Vor einiger Zeit wurde von SCM-Manager-Benutzern der Wunsch geäußert, eigene Links zum SCM-Manager hinzuzufügen, z.B. für ein Impressum oder das zugehörige Issue-Tracker-System. Diese Links sollten konfigurierbar und von jeder Seite im SCM-Manager erreichbar sein. Aus diesem Grund haben wir uns entschieden, ein Custom-Links-Plugin als Beispiel für die Erstellung von Plugins für den SCM-Manager zu schreiben. Es besteht aus einem Backend-Speicher zur Persistenz der Daten, einer REST-API-Ressource, einer Frontend-Konfigurationsseite im Admin-Bereich und einer Erweiterung zur Anzeige unserer Links in der Fußzeile von SCM-Manager.
Bootstrap für unser Plugin
Zunächst müssen wir unser Plugin einrichten. Wir verwenden create-plugin.scm-manager.org und geben die erforderlichen Informationen ein.
Nach dem Absenden erhalten wir ein Paket mit der Grundstruktur unseres Plugins, mit dem wir beginnen müssen.
Allgemeine Hinweise
- SCM-Manager ist unter der AGPL-3.0-Lizenz lizenziert. Daher muss fast jede Datei einen gültigen Lizenz-Header enthalten. Wenn Sie auf Fehler bezüglich Ihrer Lizenz-Header stoßen, versuchen Sie
./gradlew licenseFormat
, um diese automatisch zu beheben. - Wir empfehlen dringend, Unit-Tests für jede von Ihnen implementierte Logik zu schreiben. Wenn Sie Probleme beim Erstellen von Unit-Tests haben oder auf der Suche nach Best Practices sind, können Sie hier oder in anderen SCM-Manager Plugins nachsehen.
Unsere IDE einrichten
Für die Plugin-Entwicklung nutzen wir IntelliJ IDEA mit folgenden Einstellungen: IDE Konfiguration
Besonders die Punkte Lizenzen
und Formatierung
können schnell von den eigentlichen Aufgaben ablenken und sollten deshalb möglichst automatisiert werden.
Datenpersistenz
Zeit zum Coden! Wir erstellen ein Datenobjekt, das unsere einzelne benutzerdefinierte Link-Entität darstellt. In unserem Fall sollten zwei Felder für Name
und Url
ausreichen.
Dieses Objekt muss serialisierbar sein, da wir es speichern wollen. Also fügen wir einige Getter, Setter und zwei Konstruktoren hinzu.
Tipp: Sie können auch die Lombok Annotationen wie @Getter
oder @NoArgsConstructor
verwenden.
public class CustomLink {
private String name;
private String url;
CustomLink() {
}
public CustomLink(String name, String url) {
this.name = name;
this.url = url;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
}
Da wir wissen, wie unsere Daten aussehen werden, können wir einen Speicher erstellen, um diese benutzerdefinierten Links zu speichern.
SCM-Manager bietet verschiedene Speichertypen wie Datenspeicher, Konfigurationsspeicher und Blob-Speicher.
Für unseren Anwendungsfall haben wir den ConfigurationEntryStore gewählt.
Dieser Speicher kann mehrere benutzerdefinierte Links unabhängig voneinander verwalten. Unser Speicher besteht aus den drei öffentlichen Methoden getAllLinks
, addLink
und deleteLink
.
package com.cloudogu.customlinks;
import com.google.common.annotations.VisibleForTesting;
import sonia.scm.store.ConfigurationEntryStore;
import sonia.scm.store.ConfigurationEntryStoreFactory;
import jakarta.inject.Inject;
import java.util.Collection;
public class CustomLinkConfigStore {
@VisibleForTesting
public static final String STORE_NAME = "custom-links";
private final ConfigurationEntryStoreFactory configurationEntryStoreFactory;
@Inject
public CustomLinkConfigStore(ConfigurationEntryStoreFactory configurationEntryStoreFactory) {
this.configurationEntryStoreFactory = configurationEntryStoreFactory;
}
public Collection<CustomLink> getAllLinks() {
return getStore().getAll().values();
}
public void addLink(String name, String url) {
//TODO Manage links permission
getStore().put(name, new CustomLink(name, url));
}
public void removeLink(String name) {
//TODO Manage links permission
getStore().remove(name);
}
private ConfigurationEntryStore<CustomLink> getStore() {
return configurationEntryStoreFactory.withType(CustomLink.class).withName(STORE_NAME).build();
}
}
Jetzt können wir bereits Daten im internen SCM-Manager Datenspeicher persistieren. Die gespeicherten Daten aus unserem Plugin könnten wie folgt aussehen:
<?xml version="1.0" ?>
<configuration type="config-entry">
<entry>
<key>Imprint</key>
<value>
<name>Imprint</name>
<url>https://scm-manager.org/imprint</url>
</value>
</entry>
</configuration>
Berechtigungen
Bevor wir uns der REST-API zuwenden, sollten wir einen Blick auf die Berechtigungen werfen. SCM-Manager verwendet Apache Shiro zur Handhabung von Berechtigungen. Jeder angemeldete User ist ein Subjekt, dem Berechtigungen erteilt werden. Für unseren Anwendungsfall möchten wir, dass jeder User die benutzerdefinierten Links sehen kann, auch ohne angemeldet zu sein. Aber nur autorisierte User können die benutzerdefinierten Links ändern.
Daher erstellen wir eine Klasse PermissionCheck.
Diese Klasse führt eine neue Berechtigung manageCustomLinks
ein und bietet zwei Prüfungen dafür.
In Shiro sieht unsere neue Berechtigung wie configuration:manageCustomLinks
aus. Wir schützen die Schreibaktionen in unserem Store mit diesen Berechtigungsprüfungen.
Sobald ein nicht autorisierter Benutzer versucht, benutzerdefinierte Links zu ändern, wird eine AuthorizationException
geworfen.
Wie erhalten die Benutzer diese Berechtigung? Sie muss ihnen gewährt werden.
Bevor dies jedoch geschehen kann, müssen wir unsere neue Berechtigung in den SCM-Manager einführen.
Dazu erstellen wir eine permissions.xml
im Ordner resources/META-INF/scm
.
<permissions>
<permission>
<value>configuration:manageCustomLinks</value>
</permission>
</permissions>
Damit unsere neue Berechtigung aus dieser Datei angezogen wird, müssen wir den Server einmal neu starten. Bevor wir dies tun, können wir noch eine Übersetzung für die neue Berechtigung eintragen. Der automatisch generierte Übersetzungsschlüssel kann durch Hinzufügen von Übersetzungen in der Datei plugins.json ersetzt werden.
REST-API
SCM-Manager verwendet eine Level 3 REST-API und stützt sich auf die HAL-Spezifikation für Hypermedia-Typen.
Um diesen Anforderungen gerecht zu werden, stellt SCM-Manager eigene Bibliotheken zur Verfügung, die eine Menge von Standardcode reduzieren.
Beginnen wir mit der Erstellung eines DTO (Data Transfer Object) aus unserer benutzerdefinierten Links-Entität.
Dazu gibt es Annotationen, die auf der Datenklasse platziert werden, um automatisch eine DTO-Objektklasse daraus zu generieren.
Mit „@GenerateDto“ wird, wie Sie sich denken können, unsere DTO-Objektklasse erstellt.
Wir müssen die Felder, die in unsere CustomLinkDto.java
aufgenommen werden sollen, mit @Include
(com.cloudogu.conveyor.Include
) markieren.
Die zweite SCM-Manager-spezifische Annotation, die wir verwenden wollen, ist @GenerateLinkBuilder
.
Diese generiert eine neue Klasse, die Link-Builder für unsere gesamte REST-API enthält (basierend auf der Annotation @Path
von jax-rs).
Wir haben uns für den spezifischen Klassennamen RestAPI entschieden, um unsere kommenden Beispiele leichter verständlich zu machen.
Wir benötigen diese Link-Builder, wenn wir Links für unsere JSON-Antworten hinzufügen, da wir die HAL-Spezifikation einhalten müssen.
@GenerateDto
@GenerateLinkBuilder(className = "RestAPI")
public class CustomLink {
@Include
@NotEmpty
private String name;
@Include
@NotEmpty
private String url;
Als nächstes erstellen wir eine REST-Ressource, die aus drei Methoden besteht, die eine Schnittstelle zu unserem Speicher bilden.
Neben dem HTTP Verb und einem Medientyp fügen wir auch die OpenAPI-Annotationen zu jeder Methode hinzu.
Diese stellen sicher, dass eine OpenAPI-Spezifikation erstellt wird und dass die REST-API im OpenAPI-Plugin ordnungsgemäß dokumentiert ist.
In der Implementierung unserer Methode getAllLinks
sammeln wir zunächst unsere Links aus dem Speicher, dann bilden wir sie auf DTOs ab und fügen den delete
-Link an jede einzelne Entität an, wenn der User die Berechtigung dazu hat.
Schließlich verpacken wir sie in ein HalRepresentation-Objekt als _embedded
und fügen weitere Links für die gesamte Collection
hinzu.
Eine spezielle Annotation, die im Allgemeinen selten verwendet wird, ist AllowAnonymousAccess
.
Sie erlaubt den öffentlichen Zugriff auf diese REST-Methode, da sie die Authentifizierungsfilter ignoriert.
Für unseren Anwendungsfall müssen wir sie auf den Endpunkt @GET
setzen.
@OpenAPIDefinition(tags = {
@Tag(name = "Custom Links", description = "Custom links plugin related endpoints")
})
@Path(CUSTOM_LINKS_CONFIG_PATH)
public class CustomLinksResource {
public static final String CUSTOM_LINKS_MEDIA_TYPE = VndMediaType.PREFIX + "custom-links" + VndMediaType.SUFFIX;
public static final String CUSTOM_LINKS_CONFIG_PATH = "v2/custom-links";
private final CustomLinkConfigStore configStore;
@Inject //jakarta.inject.Inject
CustomLinksResource(CustomLinkConfigStore configStore) {
this.configStore = configStore;
}
@GET
@Path("")
@Produces(CUSTOM_LINKS_MEDIA_TYPE)
@Operation(
summary = "Get all custom links",
description = "Returns all custom links.",
tags = "Custom Links",
operationId = "custom_links_get_all_links"
)
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = CUSTOM_LINKS_MEDIA_TYPE,
schema = @Schema(implementation = HalRepresentation.class)
)
)
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
@AllowAnonymousAccess
public HalRepresentation getAllCustomLinks(@Context UriInfo uriInfo) {
RestAPI restAPI = new RestAPI(uriInfo);
Collection<CustomLink> customLinks = configStore.getAllLinks();
List<CustomLinkDto> linkDtos = mapCustomLinksToDtos(restAPI, customLinks);
return new HalRepresentation(createCollectionLinks(restAPI), Embedded.embedded("customLinks", linkDtos));
}
Hinweis: Die Klasse RestAPI
wird über Annotationen generiert. Damit diese Klasse zur Verfügung steht, muss ein ./gradlew build
ausgeführt werden.
Nachdem wir unsere REST-Ressource implementiert haben, ist unser Backend bereit und wir können es bereits mit curl testen, z.B. curl http://localhost:8081/scm/api/v2/custom-links
.
Aber wie genau weiß das SCM-Manager-Frontend von unseren neuen REST-Endpunkten und wie sie zu verwenden sind? Wir wollen keine Links oder URLs im Code des Frontends fest codieren.
Enricher
Die Antwort lautet Enricher. In unserem Fall benötigen wir nur einen Enricher, aber es gibt mehrere Objekte, die angereichert werden können. Beim Zugriff auf SCM-Manager holt sich das Frontend immer den Index (http://localhost:8081/scm/api/v2). Je nachdem, woraus der Index besteht, weiß das Frontend, welche Seiten, Navigationspunkte und Aktionen gerendert werden müssen. Das bedeutet, dass alle Berechtigungsprüfungen im Frontend darauf basieren, welche Links vorhanden sind.
Beispiel: Unser User darf keine Repositories anlegen. Er meldet sich in SCM-Manager an und der Index wird geholt.
Im Index finden wir den Link zum Abrufen der Repositories. Der Link „Repositories“ ruft also alle Repositories ab, die unser User sehen kann.
In der JSON-Antwort der Repository-Collection finden wir die Repositories als _embedded
und auch wieder einige _links
.
Wir haben den self
-Link und einige Paginierungs-Links, aber keinen create
-Link. Das bedeutet, dass das Frontend den Create Repository
-Button nicht darstellen darf.
Der IndexDtoGenerator erstellt den Index für jeden User auf der Grundlage der jeweiligen Berechtigungen.
Wir reichern diesen Index mit unseren „benutzerdefinierten Links“ an, indem wir einen IndexLinkEnricher erstellen.
In unserem Anwendungsfall fügen wir dem Index zwei verschiedene Links hinzu. customLinks
wird verwendet, um die benutzerdefinierten Links abzurufen, die für alle User verfügbar sein müssen.
customLinksConfig
wird verwendet, um zu prüfen, ob unser angemeldeter Benutzer die benutzerdefinierten Links auf der Administrationsseite verwalten darf.
@Extension // sonia.scm.plugin.Extension
@Enrich(Index.class) // sonia.scm.api.v2.resources.Enrich
public class IndexLinkEnricher implements HalEnricher {
private final Provider<ScmPathInfoStore> scmPathInfoStore;
@Inject
public IndexLinkEnricher(Provider<ScmPathInfoStore> scmPathInfoStore) {
this.scmPathInfoStore = scmPathInfoStore;
}
@Override
public void enrich(HalEnricherContext context, HalAppender appender) {
String link = new RestAPI(scmPathInfoStore.get().get().getApiRestUri()).customLinks().getAllCustomLinks().asString();
appender.appendLink("customLinks", link);
if (PermissionCheck.mayManageCustomLinks()) {
appender.appendLink("customLinksConfig", link);
}
}
}
Fazit
Das war’s. Unser Custom-Links-Plugin Backend ist fertig. Mit einem HTTP Request über curl sehen wir, dass unsere Daten wie erwartet zugeliefert werden. Wir können mit der Frontend-Integration beginnen. Wenn Sie es nicht abwarten können, können Sie bereits einen Blick darauf werfen, wie wir das Frontend aufbauen. Die detaillierten Schritt-für-Schritt-Erklärungen folgen im nächsten und letzten Blog-Beitrag dieser Serie.
▶ curl http://localhost:8081/scm/api/v2/custom-links | jq
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 165 100 165 0 0 23571 0 --:--:-- --:--:-- --:--:-- 23571
{
"_links": {
"self": {
"href": "http://localhost:8081/scm/api/v2/custom-links"
}
},
"_embedded": {
"customLinks": [
{
"name": "Imprint",
"url": "https://scm-manager.org/imprint"
}
]
}
}
Änderungshinweis Die ursprüngliche MIT-Lizenz von SCM-Manager wurde im Juli 2024 auf AGPL-3.0 geändert. Dieser Text wurde entsprechend angepasst.
SCM-Manager
Der einfachste Weg Ihre Git-, Mercurial- and Subversion-Repositories zu teilen und zu verwalten.
Jetzt kennenlernen