Become a member!

Delphi Minimal API: einfache, schnelle REST-APIs mit DMVCFramework

🌐
Dieser Artikel ist auch in anderen Sprachen verfügbar:
🇬🇧 English  •  🇮🇹 Italiano  •  🇪🇸 Español  •  🇧🇷 Português

Einfache, schnelle REST-APIs und Web-Apps in Delphi — Routen mit anonymen Methoden, ohne Controller-Klasse, mit DMVCFramework, dem beliebtesten Open-Source-Delphi-Framework.

Ein neuer Weg, APIs in Delphi mit DMVCFramework zu schreiben

DMVCFramework wurde geschaffen, um “große”, komplexe Probleme zu lösen: langlebige Serveranwendungen mit vielen Endpunkten, komplexen Geschäftsregeln und Teams, die jahrelang daran arbeiten. Genau deshalb war das Controller-Action-Modell schon immer das Herzstück des Frameworks. Es ist robust, testbar, skalierbar und erzwingt eine klare Struktur, je größer das Projekt wird: Es ist diese Solidität, die DMVCFramework zum am weitesten verbreiteten Open-Source-Projekt für Webanwendungen in Delphi gemacht hat, und das wird auch so bleiben.

In den letzten Jahren habe ich jedoch beobachtet, wie sich ein neuer Ansatz im Ökosystem etabliert hat: FastAPI in Python, Minimal API in ASP.NET Core, Hono in TypeScript. Alle bieten für bestimmte Szenarien eine direktere Alternative: Microservices, Prototypen, einfache APIs.

Ich wollte dieselbe Wahlmöglichkeit nach DMVCFramework bringen. Und es funktioniert.

Das Projekt hat inzwischen mehr als 50 Mitwirkende: eine wachsende Community, die es ermöglicht, an ambitionierten Features wie diesem zu arbeiten.

Und es geht nicht nur um Code: Die Minimal API ist bereits in den IDE-Wizard integriert, mit zwei neuen Vorlagen im Delphi Object Repository (wir sehen sie uns später im Detail an).

Die Presets Minimal API RESTful und Minimal API WebApp im Delphi Object Repository

Hallo, Welt

Zuallererst: Wie minimal ist es wirklich?

AEngine.Root
  .MapGet('/hello',
    function: IMVCResponse
    begin
      Result := Ok('Hello, World!');
    end);

Drei Zeilen. Keine Klasse, kein Attribut, keine eigene Datei.

Was gibt es zurück? Die anonyme Methode hat den Rückgabetyp IMVCResponse, das Objekt, das die gesamte HTTP-Antwort beschreibt (Status, Header, Body). Ok(...) ist eine Factory, die eines mit dem Status 200 OK erzeugt, und der Body ist immer ein JSON-Objekt: Übergibt man ihr einen String, wird dieser im Feld message gekapselt. Die Antwort, die beim Client ankommt, lautet also:

HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8

{"message":"Hello, World!"}

Übergibt man Ok(...) hingegen ein Objekt oder eine Liste, landen diese im Feld data desselben JSON-Objekts ({"data": ...}): Es ist derselbe Mechanismus wie beim Controller-Modell, hier über eine Funktion statt über eine Methode bereitgestellt.


So sieht es in einem echten Projekt aus

Bevor wir uns den Code ansehen, ein Wort zu den Filtern. Es gibt zwei Arten: EndpointFilter, die sich über .Use(...) an eine bestimmte Routengruppe hängen, und HTTPFilter, die auf Engine-Ebene registriert werden und unabhängig vom Routing auf jede Anfrage angewendet werden, wobei sie direkt auf dem HTTP-Transport (Ctx.Request/Ctx.Response) arbeiten. Beide sind das konzeptionelle Äquivalent zur klassischen Middleware von DMVCFramework, und wir gehen später ausführlich darauf ein. Im folgenden Beispiel verwenden wir die ersteren: Jede Route der Gruppe durchläuft sie der Reihe nach, bevor sie den Handler erreicht. Fürs Erste genügt es zu wissen, dass .Use() bedeutet: “Wende dieses Verhalten auf alles Folgende an.”

Hier ist eine vollständige REST-API (JWT-Authentifizierung, Logging, CRUD-Endpunkte) in einer einzigen Datei, ohne eine einzige Controller-Klasse:

procedure RegisterRoutes(AEngine: TMVCEngine);
begin
  // Öffentlicher Health-Check, keine Filter
  AEngine.Root
    .MapGet('/health',
      function: IMVCResponse
      begin
        Result := Ok('OK');
      end);

  // /api/v1-Gruppe: Authentifizierung und Logging für alle Routen
  AEngine.Prefix('/api/v1')
    .Use(RequestLoggingFilter())
    .Use(BearerAuthFilter())

    // Per Konvention wird jedes Interface (wie IPeopleService) aus dem DI-Container aufgelöst
    .MapGet<IPeopleService>('/people',
      function(Svc: IPeopleService): IMVCResponse
      begin
        Result := Ok(Svc.GetAll);
      end)

    .MapGet<string, IPeopleService>('/people/($id)',
      function(ID: string; Svc: IPeopleService): IMVCResponse
      begin
        Result := Ok(Svc.GetByID(ID));
      end)

    .MapPost<TPerson, IPeopleService>('/people',
      function(Person: TPerson; Svc: IPeopleService): IMVCResponse
      begin
        Svc.Create(Person);
        Result := Created('/api/v1/people/' + Person.ID.ToString, Person);
      end);
end;

Das ist alles. Kein [MVCPath], kein [MVCHTTPMethod], keine Controller-Klasse zu deklarieren.


Große Anwendungen: Struktur verschwindet nicht, Sie wählen sie

Die berechtigte Frage lautet: “Was ist, wenn ich 80 Routen habe?”

Kein Minimal-Routing-Framework skaliert gut, wenn alles in einem einzigen RegisterRoutes landet. Der Unterschied ist, dass die Struktur bei DMVC Minimal API nicht durch die Syntax aufgezwungen wird, sondern von Ihnen, auf die für Ihre Domäne sinnvollste Weise bestimmt.

Das naheliegende Muster ist, Routen nach Fachbereich aufzuteilen, jeder in einer eigenen Prozedur (oder Unit), wobei die Gruppe als Parameter übergeben wird:

// PeopleRoutesU.pas
procedure RegisterPeopleRoutes(AGroup: TMVCRouteGroup<TObject>);
begin
  AGroup
    .MapGet<IPeopleService>('/people',        ...)
    .MapGet<string, IPeopleService>('/people/($id)', ...)
    .MapPost<TPerson, IPeopleService>('/people',    ...)
    .MapPut<string, TPerson, IPeopleService>('/people/($id)', ...)
    .MapDelete<string, IPeopleService>('/people/($id)', ...);
end;

// OrdersRoutesU.pas
procedure RegisterOrdersRoutes(AGroup: TMVCRouteGroup<TObject>);
begin
  AGroup
    .MapGet<IOrderService>('/orders',          ...)
    .MapPost<TOrder, IOrderService>('/orders', ...);
end;

// RoutesU.pas: zentraler Zusammenführungspunkt
procedure RegisterRoutes(AEngine: TMVCEngine);
var
  lAPI: TMVCRouteGroup<TObject>;
begin
  lAPI := AEngine.Prefix('/api/v1')
    .Use(RequestLoggingFilter())
    .Use(BearerAuthFilter());

  RegisterPeopleRoutes(lAPI);
  RegisterOrdersRoutes(lAPI);
end;

Das Ergebnis ist eine explizite Baumstruktur: Gemeinsame Filter leben an einer Stelle, jeder Fachbereich in seiner eigenen Datei, und der Zusammenführungspunkt ist eine Liste von Aufrufen, die in zehn Sekunden lesbar ist.

Beim klassischen Controller-Modell ist Struktur vorhanden, aber sie wird durch die Syntax erzwungen: Jede Ressource benötigt eine eigene Klasse. Mit Minimal API ist sie optional: Man kann mit allem in einer Datei beginnen und refaktorieren, wenn es sinnvoll ist, ohne irgendetwas neu schreiben zu müssen.


Wie die Argumentbindung funktioniert

Jeder Parameter der anonymen Methode wird nach dem Typ automatisch aufgelöst:

Parametertyp Quelle
TWebContext HTTP-Kontext der aktuellen Anfrage
Interface im DI-Container Injizierter Service
Klasse JSON-Body (POST/PUT/PATCH) oder Query-String (GET/DELETE)
Record mit [MVCFromBody] / [MVCFromQueryString] / [MVCFromContentField]-Attributen Gemischte Bindung, Feld für Feld
string, Integer, TGUID, TDateTime, … Routenparameter (falls vorhanden), sonst Query-String

Es ist nicht nötig anzugeben, woher jeder Parameter stammt: Das Framework leitet es aus dem Typ und dem Kontext ab.


Filter: zwei Ebenen, eine Pipeline

Eines der Dinge, mit denen ich bei dieser Implementierung am zufriedensten bin, ist das zweistufige Filtermodell.

EndpointFilter: pro Gruppe, wird nur ausgelöst, wenn eine Route übereinstimmt:

AEngine.Prefix('/admin')
  .Use(
    function(Ctx: TWebContext; Next: TMVCEndpointFilterNext): IMVCResponse
    begin
      if not IsAdmin(Ctx) then
        Result := Status(403, 'Forbidden')
      else
        Result := Next();  // weiter zum nächsten Filter oder zum Handler
    end);

HTTPFilter: wird auf Engine-Ebene registriert und umhüllt die gesamte Verarbeitung jeder Anfrage, noch bevor das Routing stattfindet, wobei er direkt auf dem HTTP-Transport (Ctx.Request/Ctx.Response) arbeitet. Ideal für Rate-Limiting, statische Dateien, Sicherheits-Header, Komprimierung:

AEngine.UseHTTPFilter(
  procedure(Ctx: TWebContext; Next: TMVCHTTPFilterNext)
  begin
    Ctx.Response.SetCustomHeader('X-Frame-Options', 'DENY');
    Next();  // lässt die Pipeline weiterlaufen
  end);

Beide Typen werden in der Reihenfolge ihrer Registrierung zusammengesetzt. Die Reihenfolge ist vorhersehbar, explizit und an einer Stelle lesbar.


Nicht nur APIs: auch Web-Apps

Minimal API ist nicht auf JSON beschränkt. Mit .AsWeb wird eine Gruppe zu einer Sammlung von serverseitig gerenderten Routen, die HTML über TemplatePro zurückgeben. RenderView und ViewData funktionieren wie erwartet, und die Session ist einfach ein Filter:

AEngine.Root.AsWeb
  .Use(MemorySession(10))
  .MapGet('/',
    function: IMVCResponse
    begin
      ViewData['title'] := 'Home';
      Result := RenderView('pages/home');
    end);

Routen vom Typ rkWeb werden automatisch aus der OpenAPI-Spezifikation ausgeschlossen: Das Framework weiß, wie es API-Endpunkte von HTML-Seiten unterscheidet.


IDE-Wizard-Unterstützung

Der Wizard sollte nicht außen vor bleiben. Im Delphi Object Repository finden Sie zwei neue Voreinstellungen:

  • Minimal API RESTful: reine JSON-API, gruppenbasierte Filter, DI-Container
  • Minimal API Web App: .AsWeb-Routen, Sessions, Login/Logout, HTMX-bereit

Ein funktionsfähiges Projekt, null Konfiguration, in 30 Sekunden einsatzbereit.


Status

Das Feature ist ab Version 3.5.0-silicon-rc3 auf dem master-Branch verfügbar. Alle 872 Integrationstests bestehen auf Win32 und Win64, gegen drei verschiedene Server-Backends (WebBroker, Indy Direct, HTTP.sys): 5.232 Ausführungen insgesamt über alle Plattform- und Server-Kombinationen.

Als Nächstes:

  • Benchmark Classic API vs. Minimal API (Spoiler: die Ergebnisse haben mich überrascht)
  • OpenAPI-3.x-Integration
  • Ein dedizierter Leitfaden mit praxisnahen Mustern, fortgeschrittenen Filtern und DI-Container-Integration

Daniele

Comments

comments powered by Disqus