Web APIs mit Azure Functions: Requesthandling (Teil 2)

post-thumb

In meinem ersten Beitrag zum Erstellen von Web APIs mit Azure Functions habe ich nützliche Pattern für die Projektstruktur und die Orchestrierung der Requests vorgestellt. Falls du diesen Beitrag noch nicht gelesen hast, dann kannst du ihn hier finden: Web APIs mit Azure Functions: Projektstruktur (Teil 1) .

Überblick

Im zweiten Teil dieser Serie greife ich den Umstand auf, dass die Requests an die Azure Function manuell in Command-/Query-Objekte umgewandelt werden müssen. Das erhöht nicht nur den Aufwand je Web API, sondern hat auch redundanten Code zur Folge. Aus diesem Grund möchte ich in diesem Beitrag Implementierung für eine Extension vorstellen, die eine generische Lösung zur Konvertierung von Requests bereitstellt.

Request-Handling in Azure Functions

Vor der Implementierung möchte ich noch einmal auf den grundsätzlichen Aufbau eines Requests eingehen.

Die für eine Web API relevanten Komponenten sind:

  • Header-Parameter
  • Query-Parameter
  • Path-Parameter
  • Content/ Body

Natürlich sind nicht immer alle Komponenten bei jedem HTTP-Verb vertreten. So sendet ein GET-Aufruf für gewöhnlich keinen Content/Body an die Web API. Auch ist der Path-Parameter nur bei bestimmten HTTP-Verbs vertreten, beispielsweise bei der Suche (GET) mittels Identifikators (ID) oder bei der Aktualisierung (PUT) einer Entität.

Seit Version 2.X der Azure Function Runtime, werden HTTP-Requests standardmäßig als HttpRequest-Objekt angegeben. Darin sind die Header- und Query-Parameter, sowie der Content/Body des Requests enthalten.

Extension-Methods

C# bietet die Möglichkeit bestehenden Klassen (Typen) Methoden hinzuzufügen, ohne eine neue abgeleitete Klasse zu erstellen. Hierzu werden statische Methoden entwickelt, welche wie Instanzmethoden für die erweiterte Klasse aufgerufen werden kann. Diese Art der Erweiterung nennt man Extension-Methods (Erweiterungsmethoden).

Diese Funktionalität möchte ich nutzen, um die Request-Konvertierung möglichst einfach zu Handhaben. Die Konvertierung soll also direkt durch einen Aufruf vom HttpRequest-Objekt heraus durchgeführt werden.

Der Aufruf sollte dann schematisch so stattfinden:

var command =  request.ConvertToCommand<customentitycommand>();</customentitycommand>

Aufbau Extension

Da das Command-/Query-Objekt sämtliche Informationen enthält, werden sowohl die Felder des Requests Bodies sowie Header-, Query- und Path-Parameter darin verwaltet.

Die Extension wird demnach nach folgendem Schema aufgebaut:

  • Content/Body (Deserialisierung des Inhalts in ein Command-/Query-Objekt)
  • Header-/Query-Parameter (Extraktion und Zuordnung der jeweiligen Header- und Query-Parameter)
  • Path-Parameter (Zuordnung der Path-Parameter zum jeweiligen Command-/Query-Objekt)

Die Extension soll natürlich so flexibel sein, dass ein Request grundsätzlich in jedes Command-/Query-Objekt konvertiert werden kann. Hierbei wird Generics in C# eine große Rolle spielen, worauf ich später in der entsprechenden Implementierung der Extension eingehen möchte.

Content-/Body-Deserialisierung

In RESTful APIs sind Objekte oft als JSON serialisiert. Dieses JSON kann mithilfe von Konvertern (z.B. Newtonsoft oder System.Text.Json) in das jeweilige Objekt serialisiert werden. Für die Verwendung des JSON-Konverters wird Microsofts JSON-Paket System.Text.Json verwendet.

Beim Aufruf der Deserialize-Methode, wird der Typ angegeben. Dabei wird ein im JSON-Format bereitgestellter String als Parameter übergeben. Der Aufruf sieht wie folgt aus:

var command = JsonSerializer.Deserialize<mycommandclass>(jsonBody);</mycommandclass>

Parameternamen, welche sich zwischen dem JSON und dem Command-/Query-Objekt unterscheiden, können durch das Attribut JsonPropertyNamen in der jeweiligen Command-/Query-Klasse spezifiziert werden.

Header-/Query-/Path-Parameter Zuordnung

Angegebene Parameter (Header, Query, Path) werden entweder im Request (Header, Query) oder über den Pfad (Path) angegeben. Um eine Zuordnung zum jeweiligen Feld der Command-/Query-Klasse herstellen zu können, werden eigene Attribute benötigt. Hierzu werden die entsprechenden Felder mit den eigenen Attributen versehen werden.

Eine Verwendung der Attribute sollte wie folgt aussehen:

[RequestHeader("x-myheader")]
public string MyHeader { get; set; }

[RequestQuery("x-myquery")]
public string MyQuery { get; set; }

[RequestPathParam("id")]
public Guid Id { get; set; }

Implementierung ConvertRequest-Extension

Die Konvertierung unterteilt sich im Wesentlichen in zwei Methoden:

  • ConvertRequestToCommand
  • AddPathParamToCommand

Wobei erstere Methode sich um die Konvertierung des Bodies und um die Zuordnung der Header-/Query-Parameter kümmert.

Die zweite Methode bildet die Zuordnung der Path-Parameter zum Command-/Query-Objekt ab.

Beide Methoden werden als static deklariert und als Extension-Methoden implementiert. Dabei bildet die Methode ConvertRequestToCommand eine Extension für HttpRequest. Die Methode AddPathParamToCommand soll eine Extension für TCommand sein.

Im folgenden listing ist die Implementierung der beiden Konvertierungs-Methoden ConvertRequestToCommand und AddPathParamToCommand dargestellt.

using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Functions.Extension.Attributes;
using Microsoft.AspNetCore.Http;

namespace Functions.Extension.Handler
{
    public static partial class HttpHandler
    {
        public static async Task<TCommand> ConvertRequestToCommand<TCommand>(this HttpRequest req,
            JsonSerializerOptions jsonSerializerOptions = null)
        {
            if (jsonSerializerOptions == null)
            {
                jsonSerializerOptions = JsonOptions;
            }

            var body = await new StreamReader(req.Body).ReadToEndAsync();

            if (string.IsNullOrEmpty(body))
            {
                body = "{}";
            }

            var command = JsonSerializer.Deserialize<TCommand>(body, jsonSerializerOptions);

            if (command != null)
            {
                var properties = command.GetType().GetProperties();

                foreach (var property in properties)
                {
                    var attributes = property.GetCustomAttributes(false);

                    var propertyType = property.PropertyType;

                    // Assign Headers if they exist in Request
                    var headerMapping = attributes.FirstOrDefault(a => a.GetType() == typeof(RequestHeader));

                    if (headerMapping != null)
                    {
                        var requestHeaderName = headerMapping as RequestHeader;

                        var hasHeaderKey =
                            req.Headers.TryGetValue(
                                requestHeaderName?.GetName(),
                                out var requestHeaderValues);

                        if (hasHeaderKey)
                        {
                            var headerValue = requestHeaderValues.FirstOrDefault();

                            var castedHeaderValue = Convert.ChangeType(headerValue, propertyType);

                            command.GetType().GetProperty(property.Name)?.SetValue(command, castedHeaderValue);
                            continue;
                        }
                    }

                    // Assign Query-Parameter if they exist in Request
                    var queryMapping = attributes.FirstOrDefault(a => a.GetType() == typeof(RequestQuery));

                    if (queryMapping != null)
                    {
                        var requestQueryName = queryMapping as RequestQuery;
                        var hasQueryKey =
                            req.Query.TryGetValue(
                                requestQueryName?.GetName(),
                                out var requestQueryValues);

                        if (hasQueryKey)
                        {
                            var queryValue = requestQueryValues.FirstOrDefault();

                            var castedQueryValue = Convert.ChangeType(queryValue, propertyType);

                            command.GetType().GetProperty(property.Name)?.SetValue(command, castedQueryValue);
                        }
                    }
                }
            }

            return command;
        }

        public static TCommand AddPathParamToCommand<TCommand, TPathParam>(this TCommand command, string pathParamName, TPathParam pathParamValue)
        {
            var properties = command.GetType().GetProperties();

            foreach (var property in properties)
            {
                var attributes = property.GetCustomAttributes(false);

                var pathMapping = attributes.FirstOrDefault(a => a.GetType() == typeof(RequestPathParam));

                if (pathMapping != null)
                {
                    var pathRequestName = pathMapping as RequestPathParam;

                    if (string.Equals(pathParamName, pathRequestName?.GetName()))
                    {
                        command.GetType().GetProperty(property.Name)?.SetValue(command, pathParamValue);
                    }
                }
            }

            return command;
        }
    }
}

Konvertierung Content/Header/Query

Zu Beginn wird der Request-Body mithilfe der Json-Deserialize-Methode in einen generischen Typparameter TCommand konvertiert.

Danach wird die PropertyInfo (Auflistung aller Properties) von TCommand erzeugt, um in einer foreach-Schleife zu prüfen, ob ein RequestHeader oder RequestQuery-Attribut gesetzt ist und die entsprechenden Namen der Header-/Query-Parameter und die des TCommands übereinstimmen.

Falls nun ein Header-/Query-Attribut gesetzt und der entsprechende Name in den Header-/Query-Parameter vorhanden ist, wird der Wert aus dem HTTP-Request der Property der des Command-/Query-Objekts zugewiesen.

Konvertierung Path-Parameter

Die Methode AddPathParamToCommand wird vom konvertierten Command-/Query-Objekt heraus aufgerufen. Dabei soll der Parameter der Command-/Query-Property zugeordnet werden.

Das Vorgehen ähnelt der Zuordnung der Header- und Query-Parameter. Da mehrere Path-Parameter vorhanden sein könnten, wurde diese Logik in eine separate Methode ausgelagert.

In der beispielhaften Implementierung wird ersichtlich, wie die Methode AddPathParamToCommand mehrfach auf ein Command-/Query-Objekt ausgeführt werden kann, um die entsprechenden Path-Parameter aus dem Request dem Command-/Query-Objekt zuzuweisen.

Beispielhafte Implementierung

Im GitHub-Repo sind die Implementierung des Request-Handlings, als auch eine beispielhafte Implementierung für API-Endpunkte, welche die Konvertierung der Requests bereits verwendet. Dabei werden auch die eingeführten Attribute für Header, Query und Path verwendet.

Zusammenfassung

Die bereitgestellte Implementierung bietet nun eine einfache Handhabung zur Konvertierung von HTTP-Requests in die jeweiligen Command- und Query-Objekte. Die Lösung verfolgt einen generischen Ansatz und konvertiert in den gewünschten Zieltyp.

Nachdem wir nun eine gute Lösung für das Request-Handling implementiert haben, benötigen wir natürlich auch ein einheitliches Response-Handling. Dieses fehlt aktuell noch und wird im nächsten Teil dieser Serie betrachtet und eine Implementierung hierfür vorgestellt.

Lernen Sie uns kennen

Das sind wir

Wir sind ein Software-Unternehmen mit Hauptsitz in Karlsruhe und auf die Umsetzung von Digitalstrategien durch vernetzte Cloud-Anwendungen spezialisiert. Wir sind Microsoft-Partner und erweitern Standard-Anwendungen bei Bedarf – egal ob Modernisierung, Integration, Implementierung von CRM- oder ERP-Systemen, Cloud Security oder Identity- und Access-Management: Wir unterstützen Sie!

Mehr über uns

Der Objektkultur-Newsletter

Mit unserem Newsletter informieren wir Sie stets über die neuesten Blogbeiträge,
Webcasts und weiteren spannenden Themen rund um die Digitalisierung.

Newsletter abonnieren