API-First vs. Code-First
Photo by Sven Mieke on Unsplash
OpenAPI ist ein offener und inzwischen etablierter Standard zur Spezifikation von REST-Schnittstellen. In diesem Artikel beschreibe ich drei unterschiedliche Ansätze wie Du eine Server-Anwendung mit passender Schnittstellendefinition erstellen kann.
- Naiver Ansatz: Express & OpenAPI
- API-First: Stoplight Studio, OpenAPI-Generator & Spring Boot
- Code-First: OpenAPI mit TypeScript und Annotationen
Naiver Ansatz: Express & OpenAPI
Ein Entwickler, der den naiven Ansatz verfolgt, schreibt das Server-Programm und pflegt die entsprechende OpenAPI-Definition. Ein einfacher Server basierend auf Express für Node.js sieht zum Beispiel so aus:
const express = require("express");
const app = express();
const port = 3000;
app.get("/helloworld", (req, res) => {
res.json({
message: "Hello world!",
});
});
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`);
});
Eine passende OpenAPI-Definition sieht dann wie folgt aus:
openapi: 3.0.1
info:
title: helloworld
version: "1.0"
description: This is a very simple Hello World API.
contact: {}
servers:
- url: "http://localhost:3000"
paths:
/helloworld:
get:
operationId: get-helloworld
summary: Get a friendly "Hello world!"
description: Just give it a try and see what happens.
tags: []
responses:
"200":
description: OK
content:
application/json:
schema:
description: Object with a message property
type: object
properties:
message:
type: string
components:
schemas: {}
Anpassungen in beiden Teilen sind zwar schnell gemacht, aber man muss aufpassen keine Änderungen zu vergessen und keine Tippfehler zu machen.
API-First: Stoplight Studio, OpenAPI-Generator & Spring Boot
Mit dem API-First-Ansatz erstellst Du zunächst die OpenAPI-Definition und generierst Dir dann mit Hilfe des OpenAPI-Generators ein Server-Skelett.
Für die Erstellung von OpenAPI-Definitionen greife ich gerne auf Stoplight Studio von Stoplight.io zurück. Die Werkzeuge sind komfortabel zu bedienen und sehr ausgereift. Alternativen sind der altbekannte Swagger Editor oder geeignete Plugins wie z. B. OpenAPI Preview für Visual Studio Code.
Welches Werkzeug auch immer man wählt, am Ende hat man eine OpenAPI-Definition, mit der man die Entwicklung von Client und Server parallel starten kann.
Als nächstes könnte man sich z. B. eine Server-Implementierung auf Basis Spring Boot generieren lassen. Dazu wird mit dem Node.js-Package-Runner npx
die openapi-generator-cli
ausgeführt.
ACHTUNG: Der aktuell stabile Generator in Version 5.3.1 generiert ein Backend auf Basis von Spring Boot 2.3.3, das für eine log4j2-Vulnerabilität anfällig ist.
npx openapi-generator-cli generate \
-i openapi.yaml \
-g spring \
-p apiFirst=true,delegatePattern=true
In der generierten Datei HelloworldApiDelegate.java
im Ordner src/main/java/org/openapitools/api/
ist das zu implementierende Interface zu finden. Diese Datei muss gelöscht werden, da sie im Build-Prozess neu erstellt wird (Fehlermeldung duplicate class).
Die Implementierung kann dann im Delegate HelloworldApiDelegateImpl.java
im gleichen Ordner vorgenommen werden:
package org.openapitools.api;
import org.openapitools.model.InlineResponse200;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.NativeWebRequest;
import java.util.Optional;
@Service
public class HelloworldApiDelegateImpl implements HelloworldApiDelegate {
private final NativeWebRequest request;
public HelloworldApiDelegateImpl(NativeWebRequest request) {
this.request = request;
}
@Override
public Optional<NativeWebRequest> getRequest() {
return Optional.of(this.request);
}
@Override
public ResponseEntity<InlineResponse200> getHelloworld() {
InlineResponse200 response = new InlineResponse200();
response.setMessage("Hello world!");
return new ResponseEntity<InlineResponse200>(response, HttpStatus.OK);
}
}
Mit folgendem Befehl kann der Server dann über das Build-Werkzeug Maven gestartet werden.
mvn compile exec:java \
-Dexec.mainClass=org.openapitools.OpenAPI2SpringBoot
Mit dem OpenAPI-Generator lassen sich neben verschiedensten Server-Skeletten auch passende Client-Implementierungen, Dokumentation, Schema- und Konfigurationsdateien generieren. Eine Liste aller Vorlagen ist auf der Projektseite zu finden. Ebenso ist es möglich, eigene Vorlagen zu entwickeln und vorhandene Vorlagen anzupassen.
Zwar haben die Vorlagen des Generators erfahrungsgemäß nicht immer die beste Qualität, aber sie ermöglichen den Fokus weg von uninteressanten Implementierungsdetails hin zur Geschäftslogik.
Code-First: OpenAPI mit TypeScript und Annotationen
Ähnlich wie beim naiven Ansatz entwickelt man bei Code-First direkt die Server-Anwendung und generiert davon ausgehend die OpenAPI-Definition, die durch die Verwendung von TypeScript und Decorator-Annotationen ermöglicht wird. Im Folgenden zeige ich Dir, wie Du mit tsoa einen Express-basierten Server implementierst und anschließend automatisch eine OpenAPI-Definition erhältst.
Zunächst installieren wir die Abhängigkeiten express
und tsoa
sowie die TypeScript-Typdefinitionen.
npm init
npm install express tsoa
npm install @types/node @types/express --save-dev
In der Datei tsoa.json
wird die Konfiguration von tsoa
vorgenommen.
entryFile
gibt den Einstiegspunkt an.controllerPathGlobs
definiert, wo Deine nachfolgend erstellte Controller-Implementierungen zu finden sind.spec
beschreibt, wo und wie die OpenAPI-Definition ausgegeben wird.- Unter
routes
gibst Du an, wo die automatisch generierten Server-Routen gespeichert werden sollen.
{
"entryFile": "src/app.ts",
"controllerPathGlobs": ["src/**/*Controller.ts"],
"spec": {
"outputDirectory": "build",
"specFileBaseName": "openapi",
"yaml": true,
"specVersion": 3
},
"routes": {
"routesDir": "build"
}
}
In der Datei src/app.ts
kannst Du nun wie zuvor den Express-Server konfigurieren, wobei RegisterRoutes
später automatisch generiert wird.
import express = require("express");
import { RegisterRoutes } from "../build/routes";
const app = express();
const port = 3000;
RegisterRoutes(app);
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`);
});
In der Datei src/helloworld/HelloWorld.ts
beschreiben wir den Ausgabetypen als Modell für unsere Funktion.
export interface HelloWorld {
message: string;
}
In der Datei src/hellowold/HelloWorldController.ts
implementieren wir einen annotierten Controller mit einer GET
-Operation für den Pfad /helloworld
.
import { Controller, Get, Route } from "tsoa";
import { HelloWorld } from "./HelloWorld";
@Route("helloworld")
export class HelloWorldController extends Controller {
@Get()
public async getHelloWorld(): Promise<HelloWorld> {
return {
message: "Hello World",
};
}
}
Mit Hilfe von tsoa
können nun die Dateien build/build/route.js
und build/openapi.yaml
generiert werden.
npx tsoa routes
npx tsoa spec
Die generierte OpenAPI-Definition sieht so aus:
components:
examples: {}
headers: {}
parameters: {}
requestBodies: {}
responses: {}
schemas:
HelloWorld:
properties:
message:
type: string
required:
- message
type: object
additionalProperties: false
securitySchemes: {}
info:
title: helloworld
version: 1.0.0
license:
name: ISC
contact: {}
openapi: 3.0.0
paths:
/helloworld:
get:
operationId: GetHelloWorld
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/HelloWorld'
security: []
parameters: []
servers:
-
url: /
Unsere Controller-Klasse und das Modell sowie den Server können wir mit dem TypeScript-Compiler in JavaScript umsetzen. Dafür benötigen wir die Konfigurationsdatei tsconfig.json
:
{
"compilerOptions": {
/* Basic Options */
"incremental": true,
"target": "es6",
"module": "commonjs",
"outDir": "build",
/* Strict Type-Checking Options */
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
/* Additional Checks */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
/* Module Resolution Options */
"moduleResolution": "node",
"baseUrl": ".",
"esModuleInterop": true,
/* Experimental Options */
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
/* Advanced Options */
"forceConsistentCasingInFileNames": true
}
}
Anschließend können wir den Compiler ausführen und den Server mit dem node
-Befehl starten:
npx tsc --outDir build --experimentalDecorators
node build/src/app.js
Das gezeigte Beispiel generiert die Routen für Express. Mit Hilfe von Handlebar-Templates ist es auch möglich Routen andere HTTP-Server zu generieren, z. B. für Azure Function Apps.
Fazit
Alle drei Ansätze können um Ziel führen. Der erste “naive” Ansatz eignet sich für kleine Projekte und sehr kleine Teams, bei denen mögliche Fehler in der API-Beschreibung zu keinen gravierenden Fehlern führt. Grundsätzlich widerspricht dieser Ansatz dem DRY-Prinzip (Don’t Repeat Yourself).
Ich bevorzuge den zweiten API-First-Ansatz für jede ernst zu nehmende Entwicklung und sobald die Teams wachsen. Zum einen bieten die vorhandenen Werkzeuge einen leichten und komfortablen Einstieg für Frontend- und Backend-Entwickler, zum anderen erlaubt der Ansatz es sich auf ein sauberes API-Design zu konzentrieren. In diesem Fall ist der Entwurf noch vollkommen unabhängig von der genauen Realisierung und mit der ersten Version eines Schnittstellenvertrags können beide Seiten, Frontend- und Backend-Entwicklung, starten und unabhängig voneinander entwickeln.
Der Code-First-Ansatz ist dann geeignet, wenn die Entwicklung rein durch die Backend-Implementierung getrieben wird und weitere Entwicklungen erst nach Fertigstellung des Backends beginnen sollen.
Tags: