Regeln zum API Entwurf

Four steps to Teams success

Regeln für die Controller

Objekt erzeugen [POST]

  1. Falls möglich: Policy abfragen, ansonsten => Forbidden
  2. Abfangen, ob ein gültiges objekt gepostet wurde, ansonsten => 400 Bad Request und Eintrag im Log (warning)

if(HandleLoggingIfObjectMissing(group, _logger)) return BadRequest(GetErrorMsgObject(Messages.StructureError));

3. Falls in 1. die Policy nicht abgefragt werden konnte, überprüfe Berechtigung, ansonsten => Forbidden
4. Erzeuge das Objekt und Eintrag im Log (info)
5. Gebe ein Created-Result mit der url zur resource zurück - außerdem einen body mit neuer id und Hateoas links.

return Created("https://" + GetHost() + "/" + response.Id, new { data = response, _links = HateoasResponses.GetCreatedLinks(GetHost(), response.GetId(), _entityName) });

Objekt editieren/einzelne Eigenschaften editieren [PUT, PATCH]

  1. Falls möglich: Policy abfragen, ansonsten => Forbidden
  2. Abfangen, ob ein gültiges Objekt gepostet wurde und die gepostete id korrekt ist, ansonsten => 400 Bad Request und Eintrag im Log (warning)

if (HandleLoggingIfIdAndObjectMissingNonGeneric(group, id, _logger)) return BadRequest(GetErrorMsgObject(Messages.StructureError));

3. Falls in 1. die Policy nicht abgefragt werden konnte, überprüfe Berechtigung, ansonsten => Forbidden
4. Editiere das Objekt und Eintrag im Log (info)
5. Falls objekt nicht gefunden werden konnte => NotFound, ansonsten Ok mit EditResponse:

return HandleEditResponse(response, id, _logger);

Hinweis zu PATCH:

*Bei dieser Methode wird eventuell nur ein primitiver Typ erwartet. Dieser wird aber in einen Wrapper gepackt, der eine "value" Eigenschaft beinhaltet.

Objekt löschen [DELETE]

  1. Falls möglich: Policy abfragen, ansonsten => Forbidden
  2. Falls in 1. die Policy nicht abgefragt werden konnte, überprüfe Berechtigung, ansonsten => Forbidden
  3. Objekt löschen
  4. Wenn objekt erfolgreich gelöscht, NoContent zurück geben. Wenn nicht, NotFound zurück geben. Dies ins Log schreiben (im ersten Fall info, im zweiten warn)

HandleDeleteResponse(response, _logger);

Hinweis: Die Frage, ob in nachfolgenden Requests, nicht doch lieber ebenfalls NoContent zurück geliefert werden sollte wird im Netz diskutiert. Allerdings tendieren die meisten es so zu machen, wie ich hier.
Die Begründung liegt darin, dass:

  • Indepotenz nicht auf das Resultat bezogen werden sollte, sondern auf den Zustand nach der Operation. D.h. der Zustand ist nach dem ersten Löschen derselbe, wie nach dem zweiten etc. Die Http-Antwort verändert sich zwar, aber das widerspricht nicht der Idempotenz.
  • Clientseitig werden NotFound-Statscodes einfach mit leeren Ergebnissen (z.B. null) umgesetzt. Es ist also sicher einem NotFound keine allzugroße Beachtung zu schenken (Exception werfen oder so)

Objekt für Id zurückgeben

  1. Falls möglich: Policy abfragen, ansonsten => Forbidden
  2. Falls in 1. die Policy nicht abgefragt werden konnte, überprüfe Berechtigung, ansonsten => Forbidden
  3. Objekt holen
  4. Falls objekt...
    1. Nicht existiert: NotFound zurück geben
    2. Existiert: data-Frame mit Objekt zurück geben

Objektliste (z.B. mit query) zurück geben

  1. Falls möglich: Policy abfragen, ansonsten => Forbidden
  2. Falls in 1. die Policy nicht abgefragt werden konnte, überprüfe Berechtigung, ansonsten => Forbidden
  3. Objektliste zurückgeben - eventuell ist die Liste leer.

Regeln für den Business-Logik Entwurf

Objekt erzeugen/editieren

  • Falls null übergeben => ArgumentMissingException werfen (base: ValidationException)
  • Falls ungültiges Objekt übergeben:
    • mit falscher Referenz => InvalidReferenceException
    • weil dynamische Eigenschaften nicht gültig sind => ValidateDynamicPropertyException
    • Falls dynamische Eigenschaften nicht geparst werden können => ParseDynamicPropertyExceptio
    • Falls ein string-Wert zu lang ist: LengthExceededException (base: ValidationException)
    • Falls ein string-Wert null ist, der gesetzt sein muss: ArgumentMissingException (base: ValidationException)
    • alle anderen => InvalidRequestException
  • Falls Objekt schon existiert => AlreadyExistsException
  • Nur für "editieren"
    • Falls Objekt für id nicht existiert: EditResponse mit editedCount = -1 zurück geben
    • Falls Objekt für die id existiert, aber das gepostete Objekt gleich war, sich also nichts geändert hat editedCount = 0 zurück geben.
    • Falls id nicht im richtigen Format oder null => IdNotValidException werfen (base: ValidationException)
    • Erfolg! Falls Objekt verändert wurde editedCount > 0 zurück geben.

GetById

Falls Objekt nicht gefunden => null zurück geben*
Falls id nicht im richtigen Format => ValidationException werfen
Falls Objekt gefunden => zurück geben

*Man könnte auch eine Exception werfen, aber es ist ja nichts "schief" gegangen, wenn einfach nichts gefunden wurde. Es kann als zulässig betrachtet werden, z.B. als "exists" Abfrage. Es wird im Web kontrovers diskutiert. Ich entscheide mich in allen APIs für diesen Ansatz.

GetList

Falls nichts gefunden => leere Liste zurück geben
Falls query im falschen Format => Exception werfen
Falls etwas gefunden => Liste mit Elementen zurück geben

Delete

Falls nicht gefunden => DeleteResponse mit 0 zurück geben. Dazu gebe auch noch die id des gelöschten Elements zurück.
Falls gefunden => DeleteResponse mit >0 zurück geben. Dazu gebe die id und das objekt selbst noch einmal zurück.

Regeln für den SDK-Entwurf

Objekt erzeugen

  • Überprüfen, ob gepostetes Objekt nicht null ist (Ensure)
  • Request an API schicken
  • Wenn Objekt erzeugt, CreateResponse mit allen erzeugten ids zurück geben.
  • Wenn Fehler, Exception werfen

Objekt editieren

  • Überprüfen, ob gepostetes Objekt nicht null ist (Ensure)
  • Request an API schicken
  • Wenn Objekt geändert zurückkehren.
  • Wenn Objekt nicht gefunden oder Fehler, Exception werfen (NotFound, RequestFailed)

GetById

Falls Objekt nicht gefunden => Exception werfen (NotFound, RequestFailed)
Falls id nicht im richtigen Format => ValidationException werfen
Falls Objekt gefunden => zurück geben

* Man könnte auch null zurück geben. In meinem Fall werden alle SDKs (auch Graph z.B.) aber meist verwendet, um ein Objekt zu validieren. Falls hier eine falsche Referenz auftaucht, bedeutet dies sogut wie immer ein Fehlverhalten: es ist also tatsächlich etwas schief gegangen. Die Business Logik sollte solche Exceptions fangen und dann mit einer entsprechenden Fehlermeldung an die UI weiter geben.
In der Business Logik selbst, gebe ich übrigens bei GetById null zurück (siehe oben), denn das bedeutet nicht unbedingt ein Fehlverhalten. Jemand könnte z.B. einfach nur nachschauen wollen, ob ein bestimmtes Objekt existiert.

GetList

Falls nichts gefunden => leere Liste zurück geben
Falls query im falschen Format => Exception werfen
Falls etwas gefunden => Liste mit Elementen zurück geben

Delete

Falls nicht gefunden oder Fehler => Exception werfen (NotFound, RequestFailed)
Falls gefunden => zurückkehren, nichts zurückgeben

Regeln für den Repository-Entwurf

GetById

Ein Repository sollte zwei Möglichkeiten bereitstellen ein Element anhand der id zu erhalten:

  1. GetById<ExceptionTypeIfNotFound> (exeption falls nicht existiert)
  2. GetById (null, falls nicht existiert)

Der erste Fall wird immer zum Validieren von Entities verwendet - hier fungiert das Repo also ähnlich, wie ein Client, der ebenfalls eine Exception wirft, wenn ein gesuchtes Element nicht gefunden wurde.
Im zweiten Fall wird einfach nur geschaut, ob es ein Element gibt.

Regeln für den Microservice-Entwurf

Circuit breaker & Exponential Backoff

  • Bei HttpRequestExceptions, server errors (500), request timeouts (408) wird ein Request 4x wiederholt. Dies geschieht mit einem "Exponential Backof", also im Abstand von 2, 4, 8 und dann 16 Sek. Ein wartender Client, würde also dadurch 30s verbrauchen, was noch unter den 100s Wartezeit des normalen HttpClients liegt.
  • Falls derselbe Request jedoch 30 Mal erfolglos versucht wird (durch mehrere Clients, oder denselben, der es immer wieder versucht), blockiert der HttpClient für eine drei Minuten (Circuit-Breaker Pattern)

Regeln fürs Exception handling

In der Software können Exceptions an verschiedenen Stellen und in verschiedenen Schichten auftreten.
Hierbei gibt es die folgenden "Arten" von Ausnahmen:

  1. Business Logik Exceptions: Liegen im "Core" der eigenen Business Logik
  2. Microservice Business Logik Exceptions: Liegen im "Core" der Business Logik aus einem anderen Microservice oder im ClientsExceptions Ordner.
  3. Cross-Cutting Exceptions: Liegen in bekannten und häufig verwendeten Bibliotheken. Z.B. Ensure-Exceptions wie "ArgumentMissing"
  4. Adapter Exceptions: Treten in Adaptern auf (eigene Adapter, oder "fremde" Adapter)
  5. C# allgemeine Exceptions: Null-Pointer etc. werden von .NET definiert
  6. Exceptions aus anderen Bibliotheken oder Frameworks

Im folgenden Bild ist das dargestellt:

Die Business Logik Exceptions 1&2 und die Cross Cutting Exceptions sind bekannt und sollten in der UI Schicht gefangen werden. Sie können außerdem problemlos als Ergebnisse für den Benutzer verwendet werden. Die UI darf ruhig eine Abhängigkeit auf die Business Logik und Cross Cutting Elemente haben.

Adapter Exceptions sollten im jeweiligen Adapter gefangen und ggf. in eine Business Logik Exception (1) umgewandelt werden. Ansonsten werden sie einfach als "fremde" Exceptions weiter nach oben gereicht.
Die UI sollte keine Abhängigkeit auf diese haben.

C# Exceptions und Exceptions aus anderen Bibliotheken sollten als "Exception" in der UI gefangen werden. Der Benutzer erhält nur eine "Something went wrong" Meldung. Die UI sollte keine Abhängigkeit haben.

Ein Beispiel

Es soll eine neue Gruppe hinzugefügt werden. Diese Gruppe soll den Benutzer mit der UserId '123' als Besitzer festlegen.
Die Business Logik hat einen "IUserService", dieser hat eine Funktion "GetById".
Der Graph Adapter verwendet das "Own Graph SDK", um den Benutzer vom Graphen zu holen. Das Own GraphSDK verwendet wiederum die originale Bibliothek von Microsoft.

Die originale Bibliothek wirft eine Exception, wenn der Benutzer nicht existiert. Diese Exception darf ich natürlich nicht in der UI fangen und eine Meldung anzeigen.
Stattdessen fange ich die Exception im GraphAdapter und wandle sie in eine NotFoundException("User with id 123 does not exist") um.
Die NotFoundException gehört zum Core und darf in der UI gefangen werden.

Ich hätte nun auch eine eigene Exception im Graph Adapter definieren können, doch welchen Sinn hätte dies? Genauso brächte es nichts die Exception in meinem "Own Graph SDK" zu fangen und neu zu werfen. Dabei geht höchstens die Stack-Trace verloren.