CodeGym /Kurse /Spring Test /JSON-Tests ApiProblem und PageResponse

JSON-Tests ApiProblem und PageResponse

Spring Test
Level 8, Lektion 3
Verfügbar

1. Warum separate JSON-Tests

Wenn es in der API DTOs gibt, die nur „gelegentlich“ vorkommen, dann trifft ihr Defekt nur einen Endpunkt und ein Szenario. Aber ApiProblem und PageResponse sind wie Schrauben in einem Flugzeug: niemand diskutiert über ihre „Funktionen“, solange sie vorhanden sind, aber wenn jemand eine Schraube „versehentlich umbenennt“, leiden plötzlich alle. Daher ist ihr JSON-Vertrag der Kandidat Nr. 1 für separate, enge Tests.

In ContentHub sind diese beiden Payloads normalerweise sofort an Dutzenden von Antworten beteiligt. ApiProblem wird zurückgegeben, wenn der Client einen Fehler erhält (und er möchte ihn fast immer maschinell verstehen: anhand von errorCode, violations, status). PageResponse erscheint in jedem „listenartigen“ Endpunkt und wird Teil der Gewohnheit des Clients: Er erwartet content und die Seitenmetadaten in einem stabilen Format.

Stellen wir uns die „Schadenszone“ in Form eines einfachen Diagramms vor. Es geht nicht um Spring, nicht um Controller und nicht um Datenbanken – sondern nur darum, welche DTOs die gemeinsamen Bausteine der API sind:

flowchart TD
    Client[API-Client] --> PublicList[GET /api/public/articles]
    Client --> PublicDetails["GET /api/public/articles/{slug}"]
    Client --> EditorList[GET /api/editor/articles]
    Client --> AdminList[GET /api/admin/articles]

    PublicList --> PageResponse[PageResponse]
    EditorList --> PageResponse
    AdminList --> PageResponse

    PublicDetails --> ArticleDetails[ArticleDetailsResponse]

    PublicList -->|Fehler| ApiProblem[ApiProblem]
    PublicDetails -->|Fehler| ApiProblem
    EditorList -->|Fehler| ApiProblem
    AdminList -->|Fehler| ApiProblem

Die Logik ist einfach: Wenn bei Ihnen ArticleDetailsResponse defekt ist, bricht der Client zusammen, wenn er die Artikelkarte liest. Wenn Sie ApiProblem defekt gemacht haben – bricht der Client „überall, wo Fehler auftreten“, also fast überall. Und Fehler treten in der Realität häufiger auf, als wir zugeben möchten (besonders in der Nacht vor dem Release).

Daraus ergibt sich eine praktische Regel für Tests: gemeinsame Payloads sollten eigene @JsonTest-Klassen haben, damit ihr Vertrag schnell und isoliert geprüft werden kann, ohne Rauschen der Web-Schicht und der Geschäftslogik.

2. Vertrag von ApiProblem

Wenn ein Entwickler „Fehler“ sagt, hört der Client normalerweise „kann ich vernünftige Felder haben, damit ich Ihren detail-String nicht mit regulären Ausdrücken parsen muss?“. Daher ist ApiProblem in unserem Kurs nicht „wie es kommt“, sondern ein stabiler Vertrag mit maschinenlesbaren Feldern. In JSON-Tests ist es wichtig, das zu fixieren, was der Client tatsächlich automatisch lesen wird, und das, was am häufigsten bei „harmlosem Refactoring“ kaputtgemacht wird.

Unten finden Sie eine praktische Tabelle mit der typischen Bedeutung der Felder von ApiProblem. Dies ist kein „Dogma für die Ewigkeit“, sondern ein Modell dessen, was Sie durch Tests schützen sollten. Auch wenn Ihre tatsächliche Klasse etwas abweicht, bleibt die Prüflogik dieselbe: Felder, von denen der Client abhängt, müssen in Name und Typ stabil sein.

Feld in JSON Typ in JSON Wofür der Client Was meistens bricht
status number Fehlerklasse schnell erkennen in String "404" umwandeln oder umbenennen
errorCode string Maschinenlesbare Ursache (ARTICLE_NOT_FOUND, ACCESS_DENIED) Feldname ändern (code) oder Wertformat
title string Kurzbeschreibung beginnen zu „lokalisieren“ ohne Absprache
detail string / null Text für Menschen plötzlich erforderlich/optional machen
type string Kategorie/URI des Fehlertyps umbenennen oder entfernen
instance string Link/Identifikator des konkreten Falls Format ändern oder entfernen
violations array / null / absent Validierungsfehler nach Feldern Struktur der Array-Elemente wird kaputtgemacht

Ein besonders tückisches Feld ist violations. Es ist leicht, es zu „verbessern“ (ein Feld hinzufügen, field in path umbenennen, message zu einem Objekt machen…), und jedes Mal bricht dies die clientseitige Fehlerformularverarbeitung. JSON-Tests sind gut darin, solche „Verbesserungen“ zu einer expliziten Entscheidung zu machen: Der Test fällt aus, und Sie müssen sich ehrlich fragen: „Ändern wir wirklich den öffentlichen Vertrag?“.

Eine weitere Nuance: Der JSON-Test von ApiProblem sollte nicht davon abhängen, wie der Fehler genau auftritt. In dieser Vorlesung testen wir weder Ausnahmen, Fehlerbehandler noch die Umwandlung von Exceptions in Antworten. Wir testen das DTO als Objekt des JSON-Vertrags: dass es bei Serialisierung korrektes JSON liefert und dass dieses JSON ohne Überraschungen zurückgelesen werden kann.

3. Test ApiProblemJsonTest

Serialisierung

Die Serialisierung von ApiProblem ist das häufigste und nützlichste Szenario für einen engen JSON-Test. Wir sagen buchstäblich: „Hier ist ein Java-Objekt, das wir als unseren Fehler betrachten; zeige mir das JSON, und ich werde prüfen, dass die Schlüsselfelder vorhanden sind, mit korrekten Namen und Typen.“ Es ist ein schneller Test, und er fällt aus, bevor Sie überhaupt die Anwendung starten und in Postman klicken können.

Beginnen wir mit einem minimalen Testgerüst. Beachten Sie: Wir heben keine Web-Schicht hoch, richten keine Controller ein, mocken keine Services. Nur den JSON-Slice.


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;

@JsonTest // Heben nur JSON-Slice hoch: ObjectMapper und Jackson-Konfiguration
class ApiProblemJsonTest {

    @Autowired
    private JacksonTester<ApiProblem> json; // Utility für Serialisierung/Parsing von DTOs in Tests
}

Nun fügen wir einen Test hinzu, der das tragende Feld fixiert – errorCode. Wenn Sie es versehentlich in code umbenennen, wird der Client, der sich auf errorCode verlassen hat, nicht „ein bisschen verärgert sein“, sondern einfach nicht mehr verstehen, was passiert ist.


import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

@Test
void writesErrorCodeAsStableField() throws Exception {
    // DTO von Hand vorbereiten: Im JSON-Test fixieren wir den Vertrag, nicht die Fehlerquelle
    ApiProblem problem = sampleArticleNotFoundProblem();

    // Überprüfen einen bestimmten JSON-Schlüssel, damit der Test nicht aufgrund der Feldreihenfolge spröde ist
    assertThat(json.write(problem))
            .extractingJsonPathStringValue("@.errorCode")
            .isEqualTo("ARTICLE_NOT_FOUND");
}

Hier mache ich absichtlich eine punktuelle Prüfung: Sie ist kurz, klar und beweist genau den Vertrag. Dabei ist es nützlich, zusätzlich status als Zahl zu fixieren, denn das ist eine klassische Falle: Jemand wandelt in einen String um „aus Einheitlichkeit“, und dann beginnt das Frontend, "404" mit der Zahl 404 zu vergleichen und verliert stillschweigend.


@Test
void writesStatusAsNumber() throws Exception {
    // Wir verwenden dasselbe „Referenz“-Fehlerbeispiel
    ApiProblem problem = sampleArticleNotFoundProblem();

    assertThat(json.write(problem))
            .extractingJsonPathNumberValue("@.status")
            // Vergleichen über intValue(), weil JsonPath einen abstrakten Number zurückgibt
            .satisfies(n -> assertThat(n.intValue()).isEqualTo(404));
}

Beachten Sie satisfies(...). Die Methode extractingJsonPathNumberValue gibt Number zurück, und Number kann Integer, Long, BigDecimal sein – abhängig vom Mapper und Kontext. Wenn Sie naiv isEqualTo(404) machen, erhalten Sie manchmal unerwartete Fehler aufgrund von Klassenungleichheit. Daher ist es beruhigender, über intValue() oder longValue() zu prüfen.

Nun das „Interessanteste“ – violations. Normalerweise ist es ein Array von Objekten (z.B. { "field": "title", "message": "must not be blank" }). Es ist wichtig zu fixieren, dass es genau ein Array ist, dass die Elemente die erwartete Struktur haben und dass die Feldnamen innerhalb der Elemente ebenfalls stabil sind.


@Test
void writesViolationsAsArrayOfObjects() throws Exception {
    // Ein spezielles Beispiel, bei dem violations genau vorhanden sind
    ApiProblem problem = sampleValidationProblem();

    var content = json.write(problem); // Serialisieren und mit JsonContent arbeiten

    // Fixieren die Struktur des Array-Elements violations: field + message
    assertThat(content).extractingJsonPathStringValue("@.violations[0].field")
            .isEqualTo("title");
    assertThat(content).extractingJsonPathStringValue("@.violations[0].message")
            .isEqualTo("must not be blank");
}

Beachten Sie den Stil: Ich vergleiche nicht das gesamte JSON „frontal“ mit einem riesigen String. Ich fixiere die Bedeutung: die Form des Arrays, die Struktur des Elements. Der vollständige Vergleich über eine Fixture-Datei ist auch ein gutes Werkzeug, aber in dieser Vorlesung ist es wichtig, den Fokus auf die tragenden Elemente des Vertrags zu halten.

Damit die Beispiele in sich geschlossen sind, zeige ich eine Variante einer einfachen Factory-Methode innerhalb des Tests. Ja, es ist architektonisch nicht ideal, aber für einen JSON-Test ist das oft in Ordnung: Das DTO ist klein, der Test lokal und der Sinn sofort lesbar.


private static ApiProblem sampleArticleNotFoundProblem() {
    ApiProblem problem = new ApiProblem();
    problem.setStatus(404); // HTTP-Status sollte als Zahl serialisiert werden
    problem.setErrorCode("ARTICLE_NOT_FOUND"); // Maschinenlesbarer Code für den Client
    problem.setTitle("Article not found"); // Kurzbeschreibung für den Menschen
    return problem;
}

Wenn bei Ihnen ApiProblem anders implementiert ist (z.B. über Konstruktor oder Builder), passen Sie es an – die Testidee ändert sich nicht: DTO erstellen → serialisieren → Schlüsselfelder prüfen.

Deserialisierung

Die Deserialisierung von Fehlern erscheint seltsam: „Wir geben den Fehler doch selbst aus, warum sollten wir ihn zurücklesen?“ Aber in der Praxis bringt diese Prüfung zwei Vorteile. Erstens fixiert sie, dass der JSON-Vertrag nicht nur „schön geschrieben“ wird, sondern auch korrekt von demselben Mapper gelesen wird (und damit wahrscheinlich auch vom Client-Mapper). Zweitens fängt sie Überraschungen im Zusammenhang mit null, fehlenden Feldern und Enum-Werten.

Der einfachste Test: Wir nehmen einen JSON-String, parsen ihn in ApiProblem, prüfen ein paar der wichtigsten Felder. Hier binden wir uns bewusst nicht daran, wie dieser Fehler entstanden ist. Wir testen den „Ausweis“ des Fehlers, nicht „wer ihn ausgestellt hat“.


@Test
void parsesApiProblemFromJson() throws Exception {
    // Minimales JSON: nur Felder, die für den Client kritisch sind
    String content = """
        {"status":404,"errorCode":"ARTICLE_NOT_FOUND","title":"Article not found"}
        """;

    ApiProblem problem = json.parseObject(content); // Deserialisierung JSON -> DTO

    // Fixieren, dass die Schlüsselfelder korrekt gelesen werden
    assertThat(problem.getStatus()).isEqualTo(404);
    assertThat(problem.getErrorCode()).isEqualTo("ARTICLE_NOT_FOUND");
}

Warum ist das gerade im Zusammenhang mit violations nützlich? Weil violations oft null, ein leeres Array oder gar nicht vorhanden ist – das sind drei verschiedene Situationen, die der Client unterschiedlich interpretieren kann. Wenn Sie die Regel nicht fixiert haben, wird sie „sich selbst aussuchen“ (abhängig von Jackson-Einstellungen, Annotationen und zufälligen Änderungen), und Sie erhalten einen instabilen Vertrag.

Zum Beispiel: Wenn Sie entschieden haben, dass bei fehlenden Verstößen das Feld violations nicht vorhanden sein soll, dann sollte Ihr JSON-Test dies bestätigen. In JacksonTester kann man das bequem durch einen vollständigen Vergleich mit einem kleinen erwarteten JSON tun, in dem das Feld fehlt. Aber auch ohne Fixture-Dateien kann man punktuell prüfen: versuchen, @.violations zu extrahieren und null zu erwarten. Ich zeige eine sanfte, aber verständliche Variante: Wir serialisieren das Problem ohne Verstöße und prüfen, dass violations nicht als Array erscheint.


@Test
void omitsViolationsWhenThereAreNone() throws Exception {
    ApiProblem problem = sampleArticleNotFoundProblem(); // Ohne violations

    assertThat(json.write(problem))
            // Fixieren explizit die gewählte Politik: Das Feld ist gar nicht vorhanden
            .doesNotHaveJsonPathValue("@.violations");
}

Ein solcher Test bewahrt Sie in Zukunft vor einer „harmlosen Verbesserung“, wenn jemand beschließt, immer violations: [] zu schreiben. Das kann eine normale Entscheidung sein, aber dann muss sie bewusst getroffen werden, denn der Client-Code könnte zwischen „keine Validierungsfehler“ und „Feld verschwunden“ unterscheiden.

4. Vertrag von PageResponse

PageResponse scheint ein langweiliges Objekt zu sein: ein Array content und ein paar Zahlen. Genau deshalb ist es gefährlich. Es wird oft ohne die nötige Vorsicht geändert: „Nennen wir totalElements in total um, weil es kürzer ist“, „machen wir statt page ein offset“, „verschieben wir die Metadaten in meta“. Für den Client ist das keine Kosmetik, sondern ein echter Breaking Change: Seine Paginierungs-UI könnte stillschweigend nicht mehr funktionieren.

In ContentHub ist PageResponse auch ein architektonischer Marker: Wir geben kein Framework-Objekt nach außen zurück (z.B. Page<T>). Wir geben unseren eigenen Vertrag zurück. Das ist nicht nur für Clients nützlich, sondern auch für Tests: Sie können leichter eine stabile Form fixieren, ohne über die inneren Details von Spring Data nachdenken zu müssen und darüber, dass Page heute ein Feld und morgen ein anderes hat.

Die typische Form von PageResponse (vereinfacht) kann so aussehen:

Feld in JSON Typ in JSON Bedeutung
content array Seitenelemente (DTO, nicht Entity)
page number Aktuelle Seitennummer (normalerweise 0-basiert oder 1-basiert – wichtig zu fixieren)
size number Seitengröße
totalElements number Gesamtzahl der Elemente
totalPages number Gesamtzahl der Seiten

Und hier wieder eine wichtige „Kleinigkeit“: Zahlen müssen Zahlen bleiben, keine Strings. Das klingt nach „wer macht denn so etwas?“, aber glauben Sie mir, eines Tages wird jemand „aus Schönheitsgründen“ totalElements in "25" verwandeln, und irgendwo im Frontend wird ein sehr mysteriöser Bug auftauchen, der so lange lebt, bis Sie einen Test hinzufügen.

5. Test PageResponseJsonTest

Serialisierung

Erstellen wir eine separate Testklasse für PageResponse. Auch wenn Sie JSON-Tests für bestimmte Seitenelemente haben (z.B. ArticleSummaryResponseJsonTest), verdient der Wrapper dennoch besondere Aufmerksamkeit: Er ist der gemeinsame und er definiert die Form der Liste.

Klassengerüst (analog zu ApiProblemJsonTest):


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;

@JsonTest // Nur Jackson + JSON-Konfiguration, ohne Web-Schicht und Controller
class PageResponseJsonTest {

    @Autowired
    private JacksonTester<PageResponse> json; // Schreiben/Lesen von PageResponse als JSON
}

Ja, hier verwende ich rohes PageResponse, um das Leben nicht gleich mit Generics zu verkomplizieren. Auf der Ebene des JSON-Vertrags ist der Wrapper wichtiger als der Typparameter. Wir prüfen trotzdem die JSON-Struktur.

Nun der Test für Metadaten, bei dem wir Zahlen als Zahlen fixieren. Das ist wichtiger, als es scheint, denn ein „Typfehler“ ist ein Bug, den man in Postman nicht mit den Augen sieht, der aber die Client-Logik zerstört.


import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

@Test
void writesTotalElementsAsNumber() throws Exception {
    PageResponse page = samplePublicArticlesPage(); // Seite mit Testdaten vorbereiten

    assertThat(json.write(page))
            .extractingJsonPathNumberValue("@.totalElements")
            // longValue(), weil totalElements oft als long gespeichert wird
            .satisfies(n -> assertThat(n.longValue()).isEqualTo(25L));
}

Warum longValue()? Weil totalElements oft als long lebt (und das ist sinnvoll), und eine JSON-Zahl kann von Jackson so gelesen/geschrieben werden, dass Sie verschiedene Subtypen von Number erhalten. Dieser Stil macht den Test stabiler.

Weiter eine minimale Prüfung von content. Wir wollen diesen Test nicht in eine „Prüfung aller Felder aller Elemente“ verwandeln – das bläht den Test schnell auf und macht ihn spröde. Aber 1–2 tragende Felder innerhalb von content zu fixieren ist nützlich, weil es beweist, dass content tatsächlich ein Array von Objekten der erwarteten Form ist.


@Test
void writesContentAsArray() throws Exception {
    PageResponse page = samplePublicArticlesPage();

    assertThat(json.write(page))
            // Prüfen, dass content ein Array von Objekten ist und das erste Element das erwartete Feld hat
            .extractingJsonPathStringValue("@.content[0].slug")
            .isEqualTo("spring-testing-basics");
}

Beachten Sie: Wir prüfen slug, weil dies ein sinnvolles Feld für das öffentliche Lesen des Artikels ist. Wenn slug plötzlich nach articleSlug umzieht oder anders serialisiert wird, fällt der Test aus und sagt: „Der Listenvertrag ist kaputt“.

Und ein kleiner Helper, damit klar ist, woher das Objekt kommt:


private static PageResponse samplePublicArticlesPage() {
    return PageResponse.of(
            0, 10, 25L, 3,
            // In content liegen DTOs, die der Client tatsächlich liest
            java.util.List.of(new ArticleSummaryResponse("spring-testing-basics", "Spring Testing Basics"))
    );
}

Verkrampfen Sie sich nicht bei der genauen Implementierung von of(...): In Ihrem Projekt kann das ein Konstruktor, Builder oder einfach new PageResponse(...) sein. Wichtig ist, dass der Test einen verständlichen Seitenzustand erstellt und seine JSON-Form prüft.

Deserialisierung

Die Deserialisierung von PageResponse ist eine weitere Möglichkeit, sich vor Überraschungen durch Vertragsänderungen zu schützen. Besonders nützlich ist es zu prüfen, dass das Objekt auch bei Fehlen einiger nebensächlicher Felder gelesen wird (wenn Sie Rückwärtskompatibilität zulassen) und dass Zahlen in die erwarteten Typen gelesen werden. Aber hier halten wir den Fokus: keine riesigen Tests, sondern nur ein paar Schlüsselsachen bestätigen.

Minimales Beispiel: JSON-String parsen, page, size prüfen und dass content nicht leer ist.


@Test
void parsesPageResponse() throws Exception {
    // JSON-String, der dem möglichst ähnlich ist, was tatsächlich an den Client geht
    String content = """
        {"page":0,"size":10,"totalElements":25,"totalPages":3,
         "content":[{"slug":"spring-testing-basics","title":"Spring Testing Basics"}]}
        """;

    PageResponse page = json.parseObject(content); // Deserialisierung JSON -> DTO

    // Fixieren die Schlüsselfelder der Paginierung
    assertThat(page.getPage()).isEqualTo(0);
    assertThat(page.getSize()).isEqualTo(10);
}

Wenn Sie totalElements prüfen möchten, tun Sie dies vorsichtig und ohne naiven Vergleich „long gegen int“. Wenn z.B. totalElements in Ihrem DTO long ist, dann prüfen Sie 25L. Wenn es Long (nullable) ist, berücksichtigen Sie null. Wir haben bereits in der vorherigen Vorlesung besprochen, dass null Teil des Vertrags ist, nicht eine „Unannehmlichkeit“.

Ein weiterer Punkt: Wenn Sie solche Tests schreiben, widerstehen Sie dem Drang, „hier noch Sortierung, Filter und alles Mögliche zu prüfen“. PageResponse handelt von der Form der Daten, nicht davon, wie diese Daten gewonnen wurden. Sobald Sie hier die Bedeutung der Suche hineinziehen, testen Sie nicht mehr den JSON-Vertrag, sondern die Geschäftslogik/das Repository/die Web-Schicht, und der Test hört auf, eng und schnell zu sein.

6. Typische Fehler bei JSON-Tests

Fehler Nr. 1: Die Fehlerquelle statt der Form von ApiProblem testen.
Es ist sehr leicht, in den Gedanken zu verfallen: „Lasst uns jetzt eine Logik aufrufen, sie wirft eine Exception, und wir sehen, was zurückkommt.“ Das ist kein JSON-Test mehr, sondern etwas anderes und viel teureres. In der JSON-Schicht prüfen Sie das DTO als Vertrag: Sie erstellen ApiProblem von Hand und fixieren seine JSON-Form ohne Beteiligung der restlichen Anwendung.

Fehler Nr. 2: Bei ApiProblem nur status prüfen und errorCode vergessen.
status ist nützlich, aber fast immer unzureichend. Der Client muss ARTICLE_NOT_FOUND von ACCESS_DENIED unterscheiden können, selbst wenn beide in verschiedenen Systemen 403/404 sein können. Wenn Sie errorCode nicht mit einer separaten Prüfung fixieren, wird genau dieses Feld als erstes „versehentlich“ kaputtgemacht (Umbenennung, Formatänderung, Lokalisierung).

Fehler Nr. 3: Numerische Felder der Seite als Strings vergleichen.
Manchmal macht der Test so etwas wie „JSON als String holen und nach "totalElements":"25" suchen“. Das ist gleichzeitig spröde und gefährlich: Sie können übersehen, dass die Zahl zu einem String geworden ist, oder umgekehrt. Korrekter ist es, Number über JsonPath zu extrahieren und über intValue/longValue zu vergleichen, um genau die Typsemantik „das ist eine Zahl“ zu fixieren.

Fehler Nr. 4: Den PageResponse-Test in eine Prüfung aller Felder jedes content-Elements verwandeln.
PageResponse ist ein gemeinsamer Wrapper. Sein Test sollte beweisen, dass der Wrapper stabil ist und dass content tatsächlich ein Array von Elementen der erwarteten Form ist. Aber wenn Sie anfangen, alle Felder des Artikels innerhalb von content im Detail zu prüfen, wird der Test lang, spröde und beginnt bei jeder Änderung von ArticleSummaryResponse zu brechen, selbst wenn der Wrapper unverändert bleibt.

Fehler Nr. 5: Sich nicht auf eine Politik für null/absence bei violations festlegen.
Wenn Sie heute violations: [] ausgeben, morgen zufällig violations: null erhalten und übermorgen das Feld ganz verschwindet, beginnt der Client-Code im Modus „rate das Serververhalten“ zu leben. Man muss eine Regel wählen (Feld fehlt / Feld null / leeres Array) und sie mit einem Test fixieren. Man kann nicht darauf hoffen, dass sich „schon irgendwie stabilisiert“.

1
Aufgabe
Spring Test, Level 8, Lektion 3
Gesperrt
JSON-Test für `ApiProblem` mit Validierungsverstoß
JSON-Test für `ApiProblem` mit Validierungsverstoß
1
Aufgabe
Spring Test, Level 8, Lektion 3
Gesperrt
JSON-Test für Page Wrapper mit Metadaten
JSON-Test für Page Wrapper mit Metadaten
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION