API-First oder API-First Design ist eines der vielen Buzzwords in der modernen Softwareentwicklung. Doch für die praktische Umsetzung von API-First-Design scheint es an Tools zu mangeln. OpenAPI-Spezifikationen (OAS) sind sehr umfangreich und diese mit Hand zu schreiben, ist durch die vielen Redundanzen im Vergleich zu Code-First fehleranfällig und ineffizient. Ergo bleibt die gängige Standardpraxis Code-First und die Generierung einer OAS aus dem Backend-Code, wodurch die Vorteile des API-First-Designs unter den Tisch fallen.
Dieser Lücke im Werkzeuggürtel der modernen Webentwicklung hat sich Microsoft angenommen und eine API-Spezifikationssprache entwickelt, die verwendet werden kann, um OAS und mehr zu emittieren. Dabei kommen Patterns und Sprachkonstrukte zum Einsatz, die aus Hochsprachen wie TypeScript und C# bekannt sind.
In diesem Beitrag betrachten wir diese API-Spezifikationssprache TypeSpec und geben einen Überblick über die wichtigsten Features der Sprache.
Es folgt eine kurze Einführung in das Thema API-First-Design. Die Ungeduldigen können direkt zum Kapitel TypeSpec
springen.
API-First-Design
API-First beschreibt den Softwareentwicklungsansatz bei dem die API mit Hilfe einer API-Spezifikationssprache wie OAS oder TypeSpec als erster Schritt im Entwicklungsprozess definiert wird. APIs spielen in der modernen Technologielandschaft eine immer wichtigere Rolle und mit dem API-First-Ansatz wird dieser Fokus auch in der Softwareentwicklung sichtbar und spürbar. Bereits 2018 wurde dieser in der Skill-Up-Umfrage API Driven Architecture als vielversprechender Ansatz in der Softwareentwicklung gesehen.
Abbildung 1: Auszug aus der Skill-Up-Umfrage 2018
Eine wesentliche Motivation für das API-First-Design liegt in der Reduktion von Redundanzen durch die Verwendung einer Single Source of Truth und der damit verbundenen Reduktion von Fehlermöglichkeiten.
Abbildung 2: Redundanzen in APIs
Abbildung 2 symbolisiert die Redundanz, die bei der Entwicklung von APIs entsteht. Sowohl im Frontend- als auch im Backend-Code werden Datenschemata definiert, die über die API ausgetauscht werden, sowie Endpunkte, die festlegen, wo und wie diese Datenschemata verwendet werden. Diese Redundanz stellt, wie jede Redundanz in der Entwicklung, eine Fehlerquelle dar.
Abbildung 3: API-First-Design mit SSOT
Die in Abbildung 3 dargestellte Vorgehensweise zeigt die Verwendung einer ausgelagerten API-Spezifikation als Single Source of Truth (SSOT). Diese SSOT wird dann mit Hilfe von Codegeneratoren in Frontend- und Backend-Stubs übersetzt. Gleichzeitig dient sie als Nachschlagewerk, um die API mit Tools wie Swagger UI schnell und einfach erkunden zu können.
Darüber hinaus können einfach weitere verschiedene Frontends aus der SSOT entwickelt werden. Selbst der Wechsel zu einer neuen Backend-Technologie wird durch die Auslagerung der API-Spezifikation vereinfacht.
Bei den generierten Stubs handelt es sich um Code, der die API lediglich syntaktisch implementiert.
OpenAPI
Die OpenAPI-Spezifikation (OAS) ist mittlerweile der De-facto-Standard unter den API-Spezifikationssprachen. Auch wenn OAS gut lesbar und weit verbreit ist, lässt sich OAS auf Grund der Verbosität nicht gut von Hand schreiben. Es fehlen Sprachkonstrukte, die eine schnelle und elegante Wiederverwendung bestimmter Muster auf allen Ebenen der beschriebenen API ermöglichen.
Der Standard lenkt Entwickelnde quasi zu einem Code-First-Ansatz, bei der die OAS aus dem Code des Backends erstellt wird.
Abbildung 4: Code-First mit OAS
Die in der Abbildung 4 dargestellte Vorgehensweise zeigt den Ablauf, wie aus dem Code des Backends eine OAS generiert wird, die dann für die Entwicklung des Frontends verwendet werden kann. Neben der Verwendung als Referenz bei der Entwicklung des Frontends mit der OAS gibt es mittlerweile unzählige Codegeneratoren für die unterschiedlichsten Frameworks, die aus der OAS die syntaktische Implementierung der API generieren.
TypeSpec
TypeSpec ist eine von Microsoft als Open-Source-Projekt entwickelte Sprache, die entwickelt wurde, um APIs zu beschreiben und andere API-Definitionssprachen, Client- und Servercodes, Dokumentationen und andere Komponenten zu generieren. Laut einer Präsentation von Mitgliedern des TypeSpec-Entwicklungsteams besteht das Hauptziel der Entwicklung von TypeSpec jedoch darin, als bessere Toolchain für die Generierung von OAS zu dienen.
Abbildung 5: API-First mit TypeSpec
Abbildung 5 zeigt die Verwendung von TypeSpec als SSOT. Aus TypeSpec wird OAS generiert, aus dem mit Hilfe vorhandener Codegeneratoren Stubs für Frontend und Backend erzeugt werden können.
TypeSpec ermöglicht die Beschreibung von APIs auf einer höheren Abstraktionsebene als der derzeitige Industriestandard OpenAPI. Dies beinhaltet die Wiederverwendung abstrakter Muster im Design der API selbst und erleichtert die Identifikation dieser Muster beim Design einer API. Darüber hinaus erlaubt TypeSpec die Definition von Best Practices. Spezifikationen können anschließend auf die Anwendung dieser geprüft werden. Die Syntax von TypeSpec weist viele Ähnlichkeiten mit bekannten Programmiersprachen wie TypeScript oder C# auf.
TypeSpec befindet sich noch in einem frühen Entwicklungsstadium. Die in diesem Blogbeitrag beschriebene Version der Sprache ist Version 0.46.0.
Neben Emittern für OAS sind auch Emitter für JSON-Schema und Protobuf verfügbar, die jedoch noch nicht voll funktionsfähig sind.
Im Folgenden werden die grundlegenden Sprachkonstrukte von TypeSpec erklärt.
Für weitere Beispiele und zum interaktiven Experimentieren bietet Microsoft einen Playground für TypeSpec
an. Der Playground übersetzt TypeSpec live zu OAS.
TypeSpec-Model
Ein TypeSpec-Model beschreibt ein Datenschema, das in der API verwendet wird. Models können DTOs sein, die über die API ausgetauscht werden, aber auch Fehlertypen, Request-Parameter oder jedes andere Datenschema.
model Pet {
id: int64;
name: string;
type?: "cat" | "dog";
}
Listing 1: TypeSpec Pet Model
Die Eigenschaften von Models werden ähnlich wie die Eigenschaften von Interfaces in TypeScript deklariert. Listing 1 zeigt ein solches Model mit dem Namen Pet
. Es hat die Eigenschaften id vom Typ int64
, name vom Typ string
. type
ist vom Typ string
und kann nur die Werte cat
oder dog
annehmen. Der Typ von type
ist dabei mit einem anonymen Union-Type in TypeScript vergleichbar. type
ist im Beispiel hierbei eine optionale Eigenschaft, was durch das Fragezeichen hinter dem Namen definiert ist.
Enums in TypeSpec
TypeSpec ermöglicht die Definition von Enums. Diese können unter anderem als Typ von Eigenschaften von Models genutzt werden. Dadurch kann beispielsweise dem anonymen Union-Type im Beispiel Listing 2 ein spezifischer Name gegeben werden. Hierdurch lässt sich der Typ mehrfach verwenden. Die Umsetzung hiervon ist in Listing 3 gegeben.
enum PetType {
"cat";
"dog";
}
Listing 2: TypeSpec PetType Enum
Das Enum PetType
könnte dann in Zeile 4 von Listing 1, wie in Listing 3 dargestellt, angewandt werden.
Listing 3: Verwendung von PetType
in Pet
Darüber hinaus müssen Enums in TypeSpec nicht vom Typ string
sein.
Decorators in TypeSpec
Decorators erlauben das Anhängen von Metadaten an Typen und Eigenschaften der Sprache. Das Konzept von Decorators ermöglicht es somit, verschiedene Arten von APIs mit TypeSpec zu beschreiben. Metadaten können hierbei Dokumentationen, Constraints und vieles mehr sein.
TypeSpec selbst enthält nur eine kleine Anzahl an Decorators. Es existieren jedoch viele offizielle TypeSpec-Bibliotheken, die weitere Decorators definieren, um typische Funktionalitäten zu implementieren. Je nach Anwendungsfall werden diese Decorators über den Import der jeweiligen Bibliotheken dem spezifischen TypeSpec-Projekt hinzugefügt. Die Decorators sind in Namespaces gekapselt, welche später in diesem Kapitel erläutert werden.
Ein Beispiel für Decorators findet sich in Listing 8. Hier sind @minLength()
und @pattern()
Decorators.
Scalars
Scalars sind Typen wie string
, int32
oder boolean
, die keine eigenen Eigenschaften haben. Scalars können mit dem extends Keyword andere Scalars erweitern.
Ein Beispiel hierfür ist in Listing 4 gegeben. Password
ist damit ein Datentyp, der bspw. in Models verwendet werden kann. Die Decorators beschreiben, dass die Passwörter eine minimale Länge von 8 haben sollen und dem regulären Ausdruck entsprechen müssen.
@minLength(8)
@pattern("^(?=.*?[#?!@$%^&*-])")
scalar Password extends string;
Listing 4: Decorators in TypeSpec
Namespaces
Namespaces erlauben die Gruppierung von Typen wie Scalars, Enums und Models in eigenen Namensräumen. Dies verhindert Namenskonflikte in großen APIs und ermöglicht klar definierte Grenzen innerhalb einer API. Namespaces und die in ihnen enthaltenen Typen können hierbei von überall leicht referenziert werden.
Namespaces selbst können wiederum auch in anderen Namespaces enthalten sein, wobei es keine maximale Verschachtelungstiefe gibt. Darüber hinaus können Namespaces mit dem using Keyword in einer anderen TypeSpec-Datei verwendet werden, wenn diese importiert wurde.
Im Beispiel Listing 8
wird in Zeile 1 eine andere TypeSpec-Datei importiert und in Zeile 2 der Namespace TypeSpec.Http importiert.
Beim importierten Namespace TypeSpec.Http
handelt es sich hierbei um einen offiziellen Namespace der Sprache, der neben Models und Scalars auch Decorators definiert. Die Decorators des Namespaces müssen hierbei in JavaScript-Dateien definiert werden. Derselbe Mechanismus, der es erlaubt Decorators in den offiziellen Namespaces zu definieren und diese anderen Namespaces durch den Import zur Verfügung zu stellen, kann auch dafür eingesetzt werden, die Sprache mit selbstdefinierten Decorators zu erweitern.
Operations
Operations definieren Operationen, die in der definierten API ausgeführt werden können. Sie entsprechen beispielsweise den Endpunkten in einer REST-API und bestehen in TypeSpec aus einem Namen, Parametern und einem Rückgabetyp. Operations können entweder mit dem Keyword is
oder innerhalb von Strukturen zur Wiederverwendung von Operationen, die Interfaces (siehe Interfaces)
genannt werden, wiederverwendet werden.
Das Keyword, um Operations zu deklarieren, ist op
. Operations gleichen in ihrer Struktur den Methoden in TypeScript. Ein Beispiel für eine Operation ist in Listing 4 gegeben. Hier ist die Operation getPet
definiert, die als Pfadparameter eine id vom Typ int64
erwartet und ein Model vom Typ Pet
zurückgibt.
op getPet(@path id: int64): Pet;
Listing 5: Einzelne Operation in TypeSpec
Der OAS-Emitter von TypeSpec erkennt die in Listing 4 definierte Operation hierbei eindeutig als GET-Operation. TypeSpec erkennt anhand des Datentyps und des Decorators der Parameter und Rückgabewerte welche HTTP-Methode einer Operation zuzuordnen ist. Die HTTP-Methode kann jedoch auch über Decorators explizit gesetzt werden.
Interfaces
Interfaces werden in TypeSpec verwendet, um Operations zu gruppieren und sie wiederverwendbar zu machen. Interfaces können hierbei generisch sein und so beispielsweise ein CRUD-Interface generisch darstellen.
Listing 5 zeigt ein Beispiel, wie ein solches CRUD-Interface wiederverwendet werden kann. Es wird hierbei ein generisches Interface erstellt, welches Operations für alle 4 CRUD-Methoden bereitstellt.
interface CRUDService<TType> {
@get get(@path id: string): TType | Error;
@post post(@path id: string, type: TType): TType | Error;
@put put(@path id: string, type: TType): void | Error;
@delete delete(@path id: string): void | Error;
}
@route("/pets")
interface petService extends CRUDService<Pet> {}
@route("/fish")
interface fishService extends CRUDService<Fish> {}
Listing 6: Wiederverwendung von generischen Interfaces in TypeSpec
Innerhalb der beiden Implementierungen des generischen Interfaces können hierbei weitere, vom Typ abhängige Methoden spezifiziert werden. Diese Verwendung von generischen Interfaces entspricht in etwa der Vererbung von Methoden von Klassen in der objektorientierten Programmierung. Ein Interface kann in TypeSpec darüber hinaus auch von mehreren anderen Interfaces erben.
Spread-Operator
Der Spread-Operator ...
erlaubt es, die Eigenschaften eines Models innerhalb eines anderen Models zu verwenden. Der Operator kopiert hierbei alle Eigenschaften des Quellmodels in das Zielmodel, ohne dabei eine Beziehung zwischen Quell- und Zielmodel herzustellen. Zusammengefasst erlaubt der Operator die Wiederverwendung von gemeinsamen Eigenschaften, ohne dabei der klassischen Vererbungsstruktur zu entsprechen. Da der Spread-Operator keine semantische Bedeutung hat, kann er auch mehrfach angewandt werden.
Listing 7 zeigt ein Beispiel für die Verwendung des Spread-Operators. Das resultierende Model Pet ist identisch mit dem in Listing 1 definierten Model Pet.
model Pet {
...HasId;
...NamedEntity;
type?: "cat" | "dog";
}
model HasId {
id: int64;
}
model NamedEntity {
name: string;
}
Listing 7: Verwendung des Spread-Operators
Zusammenhängendes Beispiel für API-First-Design mit TypeSpec
Ein zusammenhängendes Beispiel für das Hello-World des API-First-Designs ist in Listing 8 gegeben.
Die generierte OAS umfasst stolze 106 Zeilen, während die TypeSpec-Spezifikation nur 34 Zeilen kurz ist.
import "@typespec/http";
using TypeSpec.Http;
@service({
title: "Swagger Petstore",
version: "1.0.0",
})
namespace DemoService;
model Pet {
id: int64;
name: string;
type?: string;
}
@maxItems(100)
model Pets is string[];
@error
model Error {
code: int32;
message: string;
}
@route("pets")
@tag("pets")
interface pets {
@get list(@query limit?: int32): {
@header("x-next") `x-next`: string;
@body pets: Pets;
} | Error;
@get read(@path id: string): Pet | Error;
@post create (...Pet): OkResponse;
}
Listing 8: TypeSpec-Spezifikation für das PetStore-Beispiel
API-First-Design mit TypeSpec: ein Fazit
Mit TypeSpec stellt Microsoft ein mächtiges Werkzeug vor, das den theoretischen Nutzen des API-First-Designs endlich praktisch anwendbar macht. Auch wenn API-First nicht für jedes Projekt der beste Ansatz ist, so gibt es doch viele Bereiche, die eindeutig von API-First profitieren.
Der Open-Source-Charakter von TypeSpec ermöglicht zudem eine einfache Erweiterung der Sprache. Vielleicht wird es neben OAS, JSON Schema und Protobuf bald noch weitere Emitter geben? Ich denke da zum Beispiel an GraphQL und erste Codegeneratoren für Programmiersprachen und Frameworks, die den Zwischenschritt über OAS - und den damit verbundenen Informationsverlust - überflüssig machen.