abshoff.dev: Web, Mobile & Cloud!

API-First vs. Code-First

Stift, Lineal und Plan

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.

  1. Naiver Ansatz: Express & OpenAPI
  2. API-First: Stoplight Studio, OpenAPI-Generator & Spring Boot
  3. 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.

Stoplight Studio

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": "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: