Pragmatische Küchentricks für RESTful HAL APIs

Seite 2: Pragmatismus statt Dogmatismus bei der Entwicklung

Inhaltsverzeichnis

Im Laufe des Projekts wurden die Module des Systems in mehreren Zyklen mit Frameworks wie Dropwizard, Rails, Grails, Ktor und zuletzt Spring Boot entwickelt. Auf der Makroebene hat sich das Projektteam frühzeitig auf HATEOAS und den Einsatz von HAL/JSON für Architektur und Standards der API festgelegt.

Entwicklung und Betrieb einer HTTP-API, selbst wenn es nur für eine relativ überschaubare Anzahl bekannter Konsumenten ist, hält neben den technischen auch organisatorische Stolpersteine bereit. Unter anderem hat sich während des Projektverlaufes der Begriff API-Hygiene etabliert, denn funktionale und nichtfunktionale Anforderungen trafen aufeinander und die Clients drängten auf eine "in ihren Augen" richtige Lösung. Der Entwicklungsprozess glich oft einem Spagat bei dem Versuch, die Anforderungen pragmatisch und schnell zu erfüllen und das richtige Augenmaß beim Befolgen der REST-Prinzipien für eine zukunftsfähige Architektur zu finden. Es folgen ein paar Beispiele, die zeigen, wie diese Gratwanderung aus Pragmatismus und Dogmatismus im Laufe des Projekts bewältigt wurde.

Für eigentlich alle Listen-Ressourcen des Projekts bietet die API über Query-Parameter zusätzliche Filteroptionen an. In Anlehnung an den obligatorischen self-Link finden sich zwei weitere Relationen mit templated-Links in der Response, vorausgefüllt für den aktuellen Filter. Dadurch konnten Anwender und technische Clients weitere Drilldowns auf der Listen-Ressource durchführen. Einer der Links führte zu einer neuen Aggregations-Ressource, die ähnlich wie ein group by in SQL, mögliche Filterwerte (auch min/max und ranges) in Buckets anbietet und so leere Suchergebnisse vermeidet.

{
  "_embedded": {
    "ex:planes": [ // hier könnten hunderte plane Ressources stehen
      {
        "_links": {
          "self": {
            "href": "http://example.com/planes/123"
          }
        },
        "makeName": "CESSNA",
        "modelName": "Skycatcher",
        "modelYear": 2018,
        "trimLevel": "PERFORMANCE",
        "wingCount": 5
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://example.com/planes?makeName=CESSNA&modelName=Skycatcher&modelYear=2018"
    },
    "ex:planes": { // aktueller Filter für Listen-Ressource
      "href": "http://example.com/planes?makeName=CESSNA&modelName=Skycatcher&modelYear=2018{&hasImages,country,trimLevel,wingCount,bodyType,andManyMore,sort,page,size}",
      "title": "PLANES (Collection)",
      "templated": true
    },
    "ex:planes_agg": { // aktueller Filter für Aggregations-Ressource
      "href": "http://example.com/planes-agg?makeName=CESSNA&modelName=Skycatcher&modelYear=2018{&hasImages,country,trimLevel,wingCount,bodyType,andManyMore}",
      "title": "PLANES_AGG (Aggregation)",
      "templated": true
    }
  }
}

Listing 3: Listen-Ressource – mit korrespondierenden Back-Links zur Aggregations-Ressource

Die Aggregations-Ressource bietet wieder zwei templated-Links, um zur originalen Suchanfrage (Listen-Ressource) zurückzukehren oder weiter auf der Aggregations-Ressource zu filtern. Durch diese parametrisierbare Verknüpfung der beiden korrespondierenden Ressourcen war es Clients möglich, komplexe Workflows abzubilden.

{
  "_links": {
    "self": {
      "href": "http://example.com/planes-agg?makeName=CESSNA&modelName=Skycatcher&modelYear=2018"
    },
    "ex:planes_agg": { // aktueller Filter für Aggregations-Ressource
      "href": "http://example.com/planes-agg?makeName=CESSNA&modelName=Skycatcher&modelYear=2018{&hasImages,country,trimLevel,wingCount,bodyType,andManyMore}",
      "title": "PLANES_AGG (Aggregation)",
      "templated": true
    },
    "ex:planes": { // aktueller Filter für Listen-Ressource
        "href": "http://example.com/planes?makeName=CESSNA&modelName=Skycatcher&modelYear=2018{&hasImages,country,trimLevel,wingCount,bodyType,andManyMore,sort,page,size}",
        "title": "PLANES (Collection)",
        "templated": true
    }
  },
  "makeName": [
    "CESSNA"
  ],
  "modelName": [
    "Skycatcher"
  ],
  "modelYear": [
    2018,
    2017
  ],
  "country": [
    "GER",
    "USA",
    "CHL"
  ],
  "trimLevel": [
    "ALLTRACK",
    "PERFORMANCE",
    "DELUXE EDITION"
  ]
}

Listing 4: Aggregations-Ressource – mit korrespondierenden Back-Links zur Listen-Ressource

Während des Entwicklungsprozesses traf das Projektteam mehrmals Entscheidungen oder wählte Abkürzungen, mit denen es zunächst nicht glücklich war, die sich aber als die pragmatischste Lösung herausstellte. Eine der Anforderungen war, dass ein zentraler Administrations-Client für eine große Anzahl von Ressourcen Batch-Operationen ausführen können soll. Dieser Client bietet Workflows, die durch einen einzelnen Klick im User Interface (UI) Hunderte von Ressourcen ändern – etwa die Zugehörigkeit zu einem Elternknoten.

Gleichzeitig wollten die Projektbeteiligten keine Client-spezifischen Endpunkte oder komplexe Transfer-Ressourcen einführen und es mit der bestehenden, uniformen API in Einklang bringen. Für derartige Sonderfälle wurden die REST-Grundregeln dezent ignoriert und eine Relation-Ressource (ohne Payload und Mediatype) entwickelt, die die Ressourcen-IDs im URI Template erwartet und nur PUT- und DELETE- Operationen kennt. Im nachfolgenden Beispiel ist das für den Link rel=ex:model-planes zu sehen.

{
    "_links": {
        "self": {
            "href": "http://example.com/models/123"
        },
        "ex:planes": {
            "href": "http://example.com/planes?modelId=123"
        },
        "ex:model-planes": [
            {
                "href": "http://example.com/models/123/planes/{planeIds}",
                "title": "MODEL has PLANES",
                "name": "modelHasPlanesTemplated",
                "templated": true
            },
            {
                "href": "http://example.com/models/123/planes/1,2,3,4,5", // aktuelle Zurordnung (Shortcut)
                "title": "MODEL has PLANES",
                "name": "modelHasPlanes"
            }
        ],
        "curies": // …
    }
}

Listing 5: Bulk-Änderungen von Ressourcen-Zuordnungen

Die Spezifikation von HAL-Links bietet erst einmal keine Mittel, um herauszufinden, welche Mediatypes im Einsatz sind und welche HTTP-Methoden – zusätzlich zu GET – für eine bestimmte Ressource erlaubt sind. Um das zu erfahren, sind zusätzliche OPTIONS- und HEAD-Requests nötig, was in der Praxis jedoch nur selten so implementiert wird. Stattdessen wird das oft hart kodiert, etwa indem Entwickler initial die Swagger-Dokumentation oder gar den API-Quellcode heranziehen.

Spring HATEOAS bietet mit der Affordances-API einen ausgereiften Ansatz, um nach HTTP-Methoden getrennte Endpunkte unter einem Relationsnamen zu bündeln. Im Standard-HAL-Mediatype ist zunächst keine Veränderung sichtbar (weiterhin nur die GET-Relation gemäß Konvention) – dazu ist eine Erweiterung wie HAL Forms nötig, die zu einem aufgeblähten LinkObject führt. Im Rahmen des Projekts hat man sich für einen einfachen Weg entschieden und das Original LinkObject um ein benutzerdefiniertes methods-Attribut erweitert. Dazu haben die Projektbeteiligten ausgehend vom URI-Pfad der GET-Relation – mit Java reflection (cached) und Projektkonventionen – die Spring Endpunkt-Annotationen @PutMapping und @DeleteMapping ausgewertet und auf diese Weise das methods-Attribut befüllt.

{
  "_links": {
    "self": {
      "href": "http://example.com/customer/jon-doe",
      "title": "Jon Doe (age 30)",
      "methods": ["GET", "PUT"] // kein Standard, aber hilfreich
    }
  },
  "name": "Jon Doe",
  "birthday": "1990-01-01"
}

Listing 6: Erweiterung der HTTP-Methoden in der HAL-JSON-Repräsentation