Anzeige
Anzeige
UX & Design
Artikel merken

REST-basierte Schnittstellen mit dem Symfony-Framework implementieren: Web Services leicht gemacht

Im Web 2.0 verfügen praktisch alle großen Anbieter über eine API. Application Programming Interfaces ermöglichen anderen Applikationen den kontrollierten Zugang zu den eigenen Daten und bilden so die Grundlage für Mash-Ups oder Third-Party-Applikationen. Das PHP-basierte MVC-Framework Symfony macht es einfach, derartige Schnittstellen effizient und sauber zu entwickeln. Dieser Artikel beleuchtet den Aufbau von solchen Application Programming Interfaces und demonstriert, wie sie sich mit Hilfe des Symfony-Frameworks implementieren lassen.

9 Min. Lesezeit
Anzeige
Anzeige

REST-basierte APIs spielen in den letzten Jahren eine immer wichtigere Rolle im Web 2.0. Mit Hilfe einer derartigen Schnittstelle können Anbieter von Webdiensten anderen Anwendungen Daten in kontrolliertem Umfang bereit stellen und sich auf diese Weise Zielgruppen jenseits der eigenen Website erschließen. APIs sind so zu einem sehr wichtigen Erfolgsfaktor geworden – man denke nur an Twitter oder Facebook und deren Ökosystem von Third-Party-Applikationen.

Anzeige
Anzeige

Mit Hilfe des Symfony-Frameworks [1] lassen sich derartige APIs auf Basis von REST [2] sehr effektiv umsetzen. Dies lässt sich am besten anhand eines beispielhaften Anwendungsfalls verdeutlichen: Ein fiktiver Buchhändler stellt eine Schnittstelle bereit, über den sich Informationen zum Angebot abfragen lassen und mit der Verleger mit Hilfe eines dedizierten Clients neue Bücher direkt in der Datenbank des Händlers speichern können.

Die Grundlagen von REST

Eine REST-basierte Schnittstelle besteht aus Ressourcen, die durch URIs adressierbar sind und in verschiedenen Repräsentationen dargestellt werden können. Ressourcen sind Informationen, die eigenständig referenziert werden sollen, beispielsweise Texte, ein Benutzerprofil oder eine Suchergebnisseite. Deren Repräsentation erfolgt meistens im XML-Format, da dieses Format die gewünschte Interoperabilität zwischen verschiedenen Systemen oder Plattformen noch am besten sicherstellen kann. Andere Formate wie JSON sind aber ebenfalls denkbar. Ressourcen lassen sich, wie auch im Web, miteinander verlinken.

Anzeige
Anzeige

Der Zugriff auf die API erfolgt ganz einfach durch das HTTP-Protokoll. Die verschiedenen Methoden des Protokolls codieren die Operationen auf der Anwendungsebene. Wird beispielsweise per GET auf eine Ressource zugegriffen, weiß der Server, dass ein Lesevorgang durchgeführt werden soll – derselbe Request mit der DELETE-Methode würde die Ressource hingegen löschen (mehr dazu unter [3] ).

Anzeige
Anzeige
Tools zum Testen von REST-APIs
REST-basierte Schnittstellen lassen sich nicht direkt im Browser
entwickeln oder testen, da sich die HTTP-Methoden für den Request nicht
festlegen lassen und auch kein direkter Zugriff auf weitere Parameter
des HTTP-Requests besteht. Abhilfe schafft das Programm „RESTClient“ (http://bit.ly/RESTClient) oder
die Firefox-Erweiterung „RestTest“ (http://bit.ly/RESTTest).
Symfony ist modular aufgebaut, einzelne Komponenten lassen sich auch eigenständig verwenden. 

Symfony ist modular aufgebaut, einzelne Komponenten lassen sich auch eigenständig verwenden.

Die API in Symfony

Die Bestandteile einer REST-API lassen sich gut in Symfony abbilden: Module beziehungsweise deren Actioncontroller stellen die Ressourcen dar, die sich mit Hilfe des Symfony-Routings beliebig adressieren lassen. Dank des MVC-Patterns kann für jede gewünschte Repräsentation ein eigenes Template definiert werden.

Das Auslesen des Bücherbestands ist der wohl einfachste Anwendungsfall: Der Request an einen URI liefert eine Liste mit Büchern. Im Grunde unterscheidet sich dieser Anwendungsfall vom herkömmlichen Abruf im Browser nur durch die Repräsentationen der Response, die im XML- anstatt im HTML-Format dargestellt wird.

Anzeige
Anzeige

Dem Routing-System von Symfony kommt bei der Umsetzung einer REST-API eine wichtige Rolle zu. Durch die Routing-Regeln wird in erster Linie festgelegt, welchen URI eine Ressource bekommt, mit welcher HTTP-Methode darauf zugegriffen werden darf und welcher Actioncontroller den Request verarbeitet.

Eine einfache Routing-Regel 
booklist: # Darstellung einer Liste mit dem Buchbestand
	url: /booklist.:sf_format
	class: sfRequestRoute
	param: { module: book, action: list, , sf_format: html }
	requirements: { sf_method: GET, sf_format: (?:xml|json|html) }

Listing 1

Im Zusammenhang mit REST-APIs spielt insbesondere der sf_format-Parameter eine wichtige Rolle. Durch ihn wird das Format bestimmt, in dem die Ressource an den Client ausgeliefert wird. Der Standardwert ist HTML, erlaubt sind in diesem Beispiel aber auch XML oder JSON. Alternativ lässt sich das Request-Format auch direkt in der Action festlegen (Listing 2), allerdings ist es eleganter, das direkt über den URI zu tun.

Request-Format im Controller setzen 
$request->setRequestFormat('xml');

Listing 2

Durch den sf_method-Parameter wird festgelegt, mit welchen Methoden eine Ressource aufgerufen werden kann. Dies kann besonders bei komplexeren APIs praktisch sein – zum Beispiel lässt sich so der Request für eine Ressource per DELETE-Methode unterbinden.

Anzeige
Anzeige

Für komplexere Requests mit mehr Parametern ist es darüber hinaus sogar möglich, bereits im Routing eine Validierung der GET-Parameter per regulärem Ausdruck vorzunehmen.

Der Aufbau von Symfony gliedert sich in eine Plattform und das darauf aufsetzende Framework. 

Der Aufbau von Symfony gliedert sich in eine Plattform und das darauf aufsetzende Framework.

Die Bücherliste kann außer im HTML-Format auch als XML- oder JSON-Repräsentation angefordert werden. Dazu müssen für jedes Format eigene Templates angelegt werden und Symfony wählt auf Basis des gesetzten sf_request-Parameters das Passende aus. Ausschlaggebend ist hierbei die Dateiendung: listSuccess.php für eine standardmäßige HTML-Repräsentation, listSuccess.xml.php für XML und listSuccess.json.php für JSON.

Symfony liefert standardmäßig nur die HTML-Repräsentation mit einem Layout aus, das bei JSON oder XML natürlich entfällt. Außerdem kümmert sich das Framework selbstständig um den korrekten Content-Type (text/xml bzw. text/x-json) im HTTP-Header.

Anzeige
Anzeige

Komplexer: Datenmanipulation

Mit den beschriebenen Funktionen hat man im Prinzip bereits alles zusammen, was man braucht, um eine sehr einfache API zum Auslesen und Bereitstellen von Daten zu implementieren. Anspruchsvoller wird es jedoch, wenn die API nicht nur Daten auslesen, sondern auch ändern, löschen oder erstellen soll. Ein weitaus komplexeres Anwendungsszenario ist daher die Implementierung einer Schnittstellenfunktion, mit der ein Verleger neue Bücher direkt aus dem eigenen Warenwirtschaftssystem in das Shop-System übertragen kann. Aus einer „lesenden“ wird so eine „schreibende“ API.

Je umfangreicher eine API wird, desto sinnvoller kann es sein, diese in eine eigenständige Applikation auszulagern um eine saubere Trennnung zu den Front- und Backend-Applikationen zu haben, die in fast jedem Symfony-Projekt vorhanden sind.

Eine neue API-Applikation samt Book-Modul ist dank der Kommandozeilentools schnell angelegt:

Anzeige
Anzeige
API-Applikation genieren 
$ symfony generate:app api
$ symfony generate:module api book

Listing 3

Für das Book-Modul das Scaffolding zu verwenden, ist durchaus möglich, aber nicht unbedingt sinnvoll: Es werden dann zwar alle nötigen Methoden im Actioncontroller angelegt, diese gehen aber davon aus, dass die Daten zum Anlegen oder Ändern von Datensätzen von den ebenfalls generierten (HTML-)Formularen in den Templates stammen. Für die API allerdings müssen diese stattdessen aus den HTTP-Bodys extrahiert werden, wo sie im XML-Format an die Schnittstelle übertragen werden.

Bei einer dedizierten Applikation für die API kann auch die View-Konfiguration geändert werden (Listing 4): Ein Layout wird jetzt nicht mehr benötigt, da die Templates aus reinem XML bestehen. Außerdem wird auch der standardmäßige Content-Type angepasst.

Anpassungen an der view.yml für die API 
default:
	http_metas:
		content-type: text/xml
	has_layout:     off

Listing 4

Symfony und das Doctrine-ORM stellen für das Routing einen nützlichen Shortcut bereit:

Anzeige
Anzeige
Shortcut in der routing.yml 
book:
	class:   sfDoctrineRouteCollection
	options: { model: Book }

Listing 5

Diese Regel wird zu einer Reihe REST-konformer Routen aufgelöst, ähnlich wie jener in Listing 1, die man sich in der Zusammenfassung auch durch die Kommandozeile anzeigen lassen kann:

Routen in der API 
$ symfony app:routes
>> app       Current routes for application
Name		Method          Pattern
book				  GET    	/book.:sf_format
book_new		 GET    	/book/new.:sf_format
book_create	 POST   	/book.:sf_format
book_edit		 GET    	/book/:id/edit.:sf_for
book_update	PUT	/book/:id.:sf_format
book_delete	 DELETE 	/book/:id.:sf_format
book_show		GET    	/book/:id.:sf_format

Listing 6

Diese Routen verweisen auf die jeweiligen Methoden (new, create, edit, update usw.) im Actioncontroller des Book-Moduls, die später implementiert werden müssen.

Für den Einsatz innerhalb einer API sollten die „Edit“- und „New“-Methoden nicht implementiert werden beziehungsweise eine Fehlermeldung werfen, da eine GET-Methode niemals in der Lage sein sollte, Daten auf einem Server in irgendeiner Weise zu ändern (oder zu erstellen). Das ist ausschließlich den idempotenten Methoden PUT, POST und DELETE vorbehalten.

Anzeige
Anzeige

Authentifizierung durch API-Keys

Natürlich sollen nur autorisierte Benutzer Datensätze erstellen oder ändern dürfen. Bevor also eine Aktion ausgeführt wird, muss sich der Benutzer zunächst gegenüber der API authentifizieren. Eine Möglichkeit dazu ist die Verwendung von API-Keys. Der API-Key kann im HTTP-Header (zum Beispiel als „X-ApiKey“) mitgeschickt werden und dient als Ersatz für die übliche Benutzernamen/Passwort-Kombination (sinnvollerweise sollten Requests an die API in diesem Fall per HTTP/S gesichert werden). Die Authentifizierung kann in Symfony als Filter (Listing 7) implementiert werden, der vor dem Abarbeiten des eigentlichen Requests im Controller durchlaufen wird und in der filter.yml aktiviert werden muss.

API-Filter Klasse 
class apiFilter extends sfFilter {
	public function execute($filterChain) {
		if ($this->isFirstCall()) {
	       $request = $this->getContext()->getRequest();
     		  $session = $this->getContext()->getUser();
             // Get API-Key from HTTP-Header
             $apikey = $request->getHttpHeader('X-ApiKey');
             // Select user from database by given API-Key
             $userprofile = Doctrine::getTable('User')->findOneByApikey($apikey);
             // Throw an error if user cannot be found...
             jeHTTPError::forwardUnless($userprofile, 401, 'Invalid or missing API-Key');
             //  ...or save the user in the session
             $session->setAttribute('user',$userprofile);
        }
		// Execute next filter in filter symfonys filter chain
		$filterChain->execute();
		// Code to execute AFTER the action execution, before the rendering
		// Delete User-Session after every API-Request
		session_unset();
		session_destroy();
	}
}

Listing 7

Dieser Filter lädt zum einen das Benutzerobjekt anhand des API-Keys im Header aus der Datenbank und speichert es in der Symfony-Benutzersession, sodass es überall im Code zur Verfügung steht. Ist der API-Key nicht korrekt, wird stattdessen eine Fehlermeldung mit entsprechendem HTTP-Statuscode gesendet. Zum anderen muss die Session wieder gelöscht werden, nachdem der Request abgearbeitet wurde. REST ist per Definition zustandslos, das heisst eine „klassische“ Session wird nicht gespeichert.

Für das Error-Handling kann wie in Listing 7 das jeHTTPErrorException-Plugin genutzt werden, welches das Symfony-Error Handling um die Fähigkeit erweitert, beliebige HTTP-Statuscodes und Fehlermeldungen in der Response zurückzugeben. Gerade im Zusammenhang mit REST ist das eine praktische Sache. Das Plugin lässt sich mit Hilfe des CLI schnell installieren:

Plugin installieren 
$ symfony plugin:install jeHTTPErrorExceptionPlugin

Listing 8

Der Controller des Book-Moduls

In Listing 5 werden Routen auf die Index-, Show-, Create-, Update-, und Delete-Methoden des Book-Controllers erzeugt, die implementiert werden müssen. Die sfDoctrineRouteCollection-Klasse koppelt dabei ein Datenbankobjekt direkt mit dem ID-Parameter in der URL, sodass man im Controller darauf zugreifen und sich eigene Queries weitgehend sparen kann. Wird kein Datensatz mit der gewünschten ID gefunden, wirft Symfony einen 404-Fehler. Die Implementierung der Controller-Methoden geht daher schnell von der Hand. Um die Dateilansicht eines Buchs anzuzeigen, reicht eine Zeile im Controller:

Detailansicht eines Buchs 
// HTTP-Request: GET /book/[id] HTTP/1.1
public function executeShow(sfWebRequest $request) {
	$this->book = $this->getRoute()->getObject();  // $this->book is now available in the template
}

Listing 9

Ein Request für denselben URI mit der DELETE-Methode wird REST-konform zur Delete-Methode geroutet:

Ein Buch löschen 
// HTTP-Request: DELETE /book/[id] HTTP/1.1
public function executeDelete(sfWebRequest $request) {
	$book = $this->getRoute()->getObject();
	// User allowed to delete this Book? If not, stop execution and send HTTP-Statuscode 403
	$this->checkPermission($book);
	// ...User has Permission, let's delete this Book
	$book->delete();
}

Listing 10

Mit den POST- und PUT-Methoden wird ein neues Buch angelegt beziehungsweise ein bestehendes Buch geändert. Die dazu erforderlichen Daten werden als XML im HTTP-Body übertragen und müssen daher ausgelesen und geparst werden. Hierzu kann man im lib-Verzeichnis in einer Klasse eine einfache Methode (Listing 11) implementieren, die bei Gebrauch durch den Autoload-Mechanismus von Symfony inkludiert wird.

Auslesen der XML-Nutzlast 
class Tools {
	public static function getRequestBody ()    {
		$stream = fopen("php://input", "r");
		$data = stream_get_contents($stream);
		fclose($stream);
		return simplexml_load_string($data);
	}
}

Listing 11

Ein neues Buch lässt sich ebenfalls sehr einfach erzeugen:

Ein neues Buch anlegen 
// HTTP-Request: POST /book HTTP/1.1
public function executeCreate(sfWebRequest $request) {
	$data = Tools::getRequestBody();
	$book = new Book();
	$book->saveBook($data);  // Save Book in the Model
	$this->getResponse()->setStatusCode(201);
	$this->getResponse()->setHttpHeader('Location',
		$request->getHost().'/book/'.$book->get('id'));
}

Listing 12

In dieser Methode werden die Daten zunächst aus dem Request-Body extrahiert und an das Model übergeben, wo das neue Buch dann angelegt wird. Wichtig ist, dem Client im Response per HTTP-Statuscode mitzuteilen, dass die Ressource erzeugt wurde („201 – Created“) und deren (neue) URI im Location-Header anzugeben.

Analog dazu funktioniert das Editieren eines Buchs:

Ein bestehendes Buch editieren 
// HTTP-Request: PUT  /book/[id] HTTP/1.1
public function executeUpdate(sfWebRequest $request) {
	$data = Tools::getRequestBody();
	$book = $this->getRoute()->getObject();
	// User allowed to edit Book? If not, stop execution, send HTTP-Statuscode 403
	$this->checkPermission($book);
	$book->saveBook($data);
}

Listing 13

Für alle genannten Methoden müssen natürlich auch die jeweiligen (XML-)Templates angelegt werden, um einem Client das gewünschte Feedback – zum Beispiel in Form einer Statusmeldung – geben zu können.

Für die Validierung der Benutzerdaten bringt Symfony eine Reihe von Standard-Validatoren mit, die allerdings primär zur Verwendung mit dem Formular-Framework gedacht sind. Noch einfacher lassen sich alternativ die Validatoren des Doctrine-ORMs [4] verwenden, die man bereits in der Schema-Datei konfigurieren kann und die die Daten noch auf der Anwendungsebene unmittelbar vor dem Speichern in der Datenbank validieren.

Zusammenfassung

Mit Hilfe des Symfony-Frameworks lassen sich REST-basierte Schnittstellen sehr sauber und vergleichsweise einfach implementieren. Dieser Artikel zeigt nur einen von vielen Ansätzen, der sich aber grundsätzlich auch für umfangreiche APIs eignet – selbst wenn für den produktiven Einsatz noch einige Aspekte tiefer ausgearbeitet werden müssten.

Mehr zu diesem Thema
Fast fertig!

Bitte klicke auf den Link in der Bestätigungsmail, um deine Anmeldung abzuschließen.

Du willst noch weitere Infos zum Newsletter? Jetzt mehr erfahren

Anzeige
Anzeige
6 Kommentare
Bitte beachte unsere Community-Richtlinien

Wir freuen uns über kontroverse Diskussionen, die gerne auch mal hitzig geführt werden dürfen. Beleidigende, grob anstößige, rassistische und strafrechtlich relevante Äußerungen und Beiträge tolerieren wir nicht. Bitte achte darauf, dass du keine Texte veröffentlichst, für die du keine ausdrückliche Erlaubnis des Urhebers hast. Ebenfalls nicht erlaubt ist der Missbrauch der Webangebote unter t3n.de als Werbeplattform. Die Nennung von Produktnamen, Herstellern, Dienstleistern und Websites ist nur dann zulässig, wenn damit nicht vorrangig der Zweck der Werbung verfolgt wird. Wir behalten uns vor, Beiträge, die diese Regeln verletzen, zu löschen und Accounts zeitweilig oder auf Dauer zu sperren.

Trotz all dieser notwendigen Regeln: Diskutiere kontrovers, sage anderen deine Meinung, trage mit weiterführenden Informationen zum Wissensaustausch bei, aber bleibe dabei fair und respektiere die Meinung anderer. Wir wünschen Dir viel Spaß mit den Webangeboten von t3n und freuen uns auf spannende Beiträge.

Dein t3n-Team

fishbone

Mit der Api-Key Lösung durch einen Filter entsteht das Problem, dass jeder vollen Zugriff hat, sobald z.B. aufgrund eines Fehlers ein Record erzeugt wurde der keinen Api-Key enthält.

Im Allgemeinen suche ich eine saubere Lösung für das Authentifizierungsproblem für Webservices in Symfony. Das wird im Jobeet-Tutorial geschickt übergangen, weil es in dem verwendeten Beispiel aufgrund des reinen Lesezugriffs nicht erforderlich ist. Um das Sessionbasierte Sicherheitssystem hinreichend anzupassen, bin ich jetzt seit Tagen damit beschäftigt, den kompletten Aufbau von Symfony zu analyisieren.
Dabei kommt mir immer wieder in den Sinn, dass es dafür bereits fertige saubere Lösungen geben müsste.
Vor allem denke ich, dass in puncto Sicherheit generell keine Workarounds angebracht sind.

Antworten
Daniel Haller

Hallo fishbone,

glaube, so ganz verstehe ich Dein Problem nicht. Zugriffe mit ungültigen API-Keys werden mit einem Fehler 401 geblockt. Natürlich sollte man die Keys so aufbauen, dass sie nicht erraten werden können und eine SSL-gesicherte Verbindung verwenden.

Wenn ich Dich richtig verstanden habe, hast Du aber mit einem anderen Anwendungsfall Bauchschmerzen: Ein User könnte sich gegenüber der API mit einem korrekten Key authentifizieren und anschließend auf Datensätze zugreifen, die nicht von ihm selbst stammen. Habe ich das richtig verstanden? Das wäre allerdings keine Lücke, die man auf Ebene der API lösen kann – dasselbe Problem hätte man schließlich auch bei einer „herkömmlichen“ Authentifizierung.
Natürlich muss man auf Applikationsebene sicherstellen, das ein bereits authentifizierter User (egal ob er sich mit einem API-Key oder Name/Passwort-Kombination authentfiziert hat) auch nur auf seine eigenen Datensätze zugreifen kann – auch dann wenn die Datensätze fehlerhaft in der DB abgelegt wurden (was ja schon an der Stelle verhindert werden müßte).

Hilft Dir das weiter? Oder habe ich Dich missverstanden?

vg,
Daniel

Antworten
fishbone

Wie bereits gesagt, entsteht das Problem nur, falls das ApiKey-Feld in der Tabelle für einen Benutzer leer ist. Wenn kein Api-Key bei der Authentifizierung übergeben wird, stimmen die ApiKeys überein (übergebener und leerer in der Datenbank).

Es ist klar, dass dies ein grundlegender Fehler ist. Die Spalte Api-Key darf natürlich nicht leer oder NULL sein. Allerdings kann dies passieren, wenn man die Schnittstelle erst in einer späteren Version hinzufügt und bereits Benutzerdaten vorhanden sind. Bei der Migration kann durchaus ein Fehler entstehen, der ein leerbleibendes ApiKey-Feld zur Folge hat. Auch wenn das unwahrscheinlich ist, möglich ist es.

Deswegen leite ich in meinem sfFilter sofort zu 401 weiter, falls kein ApiKey übergeben worden ist. Ich weiß aber nicht mehr, wie ich das im Detail gelöst habe. Denn wenn sich jemand über Cookie authentifiziert, wird (korrekterweise) auch kein ApiKey übergeben.

Zum Thema saubere Lösung:
Schnittstellen sind kein Sonderfall. Trotzdem muss man sich mit dem Aufbau des Sicherheitssystem befassen, während man für die normale Authentifizierung auf die offiziellen Symfonyplugins zurückgreifen kann. Vielleicht hätte ich da von Symfony einfach mehr erwartet. Aber schon Methoden wie forward404If() zeigen, dass Symfony 1 nicht unbedingt für REST ausgelegt ist.

Antworten
Daniel Haller

Ja, da hast Du recht – die Version 1.x von symfony ist noch nicht so 100%ig RESTfull wie das bspw. bei Rails der Fall ist. Ich kam leider noch nicht dazu, mich mit 2.x auseinander zu setzen, aber soweit ich bisher gelesen habe, wird hier sehr viel stärker auf eine RESTartige Architektur gesetzt. Ich würde mich wundern, wenn im Rahmen dessen nicht auch eine Authentifizierungslösung integriert wärde.

Ein leeres API-Key Feld darf aber natürlich nicht sein wenn man eine Schnittstelle implementiert, das ist klar. Ich bin in meinem Fall davon ausgegangen, dass es sich schon auf DB-Ebene um ein NOT NULL-Feld handelt, dass in jedem Fall befüllt sein muss – vielleicht hätte ich in dem Artikel noch darauf hinweisen sollen.

Antworten
fishbone

Auf jeden Fall danke ich für den Artikel, der mich bei diesem Projekt, das inzwischen abgeschlossen ist, sehr unterstützt hat.

Antworten
Daniel Haller

Freut mich zu hören, Dankeschön :)

Antworten

Melde dich mit deinem t3n Account an oder fülle die unteren Felder aus.

Bitte schalte deinen Adblocker für t3n.de aus!
Hallo und herzlich willkommen bei t3n!

Bitte schalte deinen Adblocker für t3n.de aus, um diesen Artikel zu lesen.

Wir sind ein unabhängiger Publisher mit einem Team von mehr als 75 fantastischen Menschen, aber ohne riesigen Konzern im Rücken. Banner und ähnliche Werbemittel sind für unsere Finanzierung sehr wichtig.

Schon jetzt und im Namen der gesamten t3n-Crew: vielen Dank für deine Unterstützung! 🙌

Deine t3n-Crew

Anleitung zur Deaktivierung
Artikel merken

Bitte melde dich an, um diesen Artikel in deiner persönlichen Merkliste auf t3n zu speichern.

Jetzt registrieren und merken

Du hast schon einen t3n-Account? Hier anmelden

oder
Auf Mastodon teilen

Gib die URL deiner Mastodon-Instanz ein, um den Artikel zu teilen.

Anzeige
Anzeige