In verschiedenen Posts bin ich auf das Paradigma API first eingegangen. Im letzten Post habe ich API first in Gänze betrachtet.
Nun möchte ich in der Rolle eines API Producers versuchen, einen Service mit der Idee von API first in die Praxis umzusetzen. Konkret geht es um die Erstellung eines Spring-Boot-Services, der über ein API mit dem Endpunkt „/api/news“ eine Liste von Nachrichten liefern soll.
Zu Beginn steht die Erstellung der OpenAPI-Spezifikation im Fokus. Sie bildet den Ausgangspunkt für jeglichen später generierten Code. Die Spezifikation ist sehr einfach gehalten und stellt sich folgendermaßen dar:
1openapi: 3.0.3 2servers: 3 - url: 'http://localhost:8080/api' 4info: 5 version: 1.0.0 6 title: News API 7 contact: 8 name: Daniel Kocot 9 url: 'http://www.codecentric.de' 10 email: daniel.kocot@codecentric.de 11 license: 12 name: MIT 13 url: https://www.tldrelgal.com/mit 14 description: An API to provide news 15tags: 16 - name: news 17paths: 18 /news: 19 get: 20 description: gets latest news 21 operationId: getNews 22 tags: 23 - news 24 responses: 25 '200': 26 description: Expected response to a valid request 27 content: 28 application/json: 29 schema: 30 $ref: '#/components/schemas/ArticleList' 31 examples: {} 32 '404': 33 description: Unexpected error 34 content: 35 application/json: 36 schema: 37 $ref: '#/components/schemas/Error' 38components: 39 schemas: 40 ArticleList: 41 title: ArticleList 42 type: array 43 items: 44 $ref: '#/components/schemas/Article' 45 Article: 46 title: Article 47 description: A article is a part of a news. 48 type: object 49 properties: 50 id: 51 type: integer 52 title: 53 type: string 54 date: 55 type: string 56 format: date 57 description: 58 type: string 59 imageUrl: 60 type: string 61 required: 62 - id 63 - title 64 - date 65 - description 66 - imageUrl 67 Error: 68 type: object 69 properties: 70 code: 71 type: string 72 messages: 73 type: string 74 required: 75 - code 76 - message 77 securitySchemes: {} 78 examples: 79 news: 80 value: 81 description: Example shared example 82 type: object 83 properties: 84 id: 85 type: string 86 required: 87 - id
Die Spezifikation wurde mithilfe von Stoplight Studio erstellt und basiert auf OpenAPI in der Version 3.0.3 . Stoplight Studio bietet eine Integration mit GitHub, somit lassen sich die Modelle und Spezifikationen einfacher versionieren. Im GitHub-Repo selbst ist auch eine GitHub Action konfiguriert, die bei Pushes auf den main-Branch oder Pull Requests auf diesen die Spezifikationen per Spectral Linter überprüft. Um den Blogpost nicht zu sprengen, werden wir bei Spectral auf das eingebaute Ruleset zurückgreifen. Spectral kommt auch gleichzeitig als Linter im Stoplight Studio zum Einsatz.
Nachdem die OpenAPI Spec zur Verfügung steht, kann ein Service auf Basis von Spring Boot und Gradle erstellt werden. Die gradle.build
-Datei sieht dabei wie folgt aus:
1plugins { 2 id 'org.springframework.boot' version '2.5.2' 3 id 'io.spring.dependency-management' version '1.0.11.RELEASE' 4 id 'de.undercouch.download' version '4.1.2' 5 id 'io.openapiprocessor.openapi-processor' version '2021.3' 6 id 'java' 7} 8 9group = 'de.codecentric' 10version = '0.0.2-SNAPSHOT' 11sourceCompatibility = '11' 12 13repositories { 14 mavenCentral() 15} 16 17dependencies { 18 implementation 'org.springframework.boot:spring-boot-starter-actuator' 19 implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 20 implementation 'org.springframework.boot:spring-boot-starter-data-rest' 21 implementation 'org.springframework.boot:spring-boot-starter-web' 22 23 developmentOnly 'org.springframework.boot:spring-boot-devtools' 24 runtimeOnly 'com.h2database:h2' 25 26 testImplementation('org.springframework.boot:spring-boot-starter-test') 27 testImplementation('org.junit.jupiter:junit-jupiter-api:5.7.2') 28 testRuntime('org.junit.jupiter:junit-jupiter-engine:5.7.2') 29 testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' 30} 31 32test { 33 useJUnitPlatform() 34} 35 36task downloadFile(type: Download) { 37 src "https://raw.githubusercontent.com/codecentric/api-showcases/main/specs/news.yaml" 38 dest "${projectDir}/src/api" 39 onlyIfModified true 40 useETag true 41} 42 43openapiProcessor { 44 spring { 45 processor 'io.openapiprocessor:openapi-processor-spring:2021.5' 46 apiPath "$projectDir/src/api/news.yaml" 47 targetDir "$projectDir/build/openapi" 48 mapping "$projectDir/mapping.yaml" 49 showWarnings true 50 } 51} 52 53afterEvaluate { 54 tasks.processSpring.dependsOn(tasks.downloadFile) 55} 56 57sourceSets { 58 main { 59 java { 60 srcDir "build/openapi" 61 } 62 } 63 64 test { 65 resources { 66 srcDir file('src/test/java') 67 exclude '**/*.java' 68 } 69 } 70} 71 72compileJava.dependsOn('processSpring') 73 74springBoot { 75 mainClassName = "de.codecentric.apifirstspringboot.ApifirstSpringbootApplication" 76}
Für die Generierung des Modells und Interfaces des API werden zwei Plug-ins benötigt. Dadurch, dass sich die Spezifikation in einem Repository auf GitHub befindet, wird ein Download-Task benötigt. Dieser wird durch das Plug-in von Michel Krämer zur Verfügung gestellt. Für den eigentlichen Generierungsschritt wird nicht der OpenAPI Generator, sondern OpenAPI Processor verwendet. OpenAPI Processor ist ein ziemlich schlankes Framework, das die OpenAPI-YAML-Datei in ein auszuwählendes Zielformat konvertiert. Neben dem Plug-in für Gradle ist auch eines für Maven erhältlich. Aktuell gibt sogenannte Prozessoren für die folgenden Zielformate:
- Spring
- Micronaut
- JSON
Die Konvertierung der OpenAPI Spec wird mittels einer mapping.yaml
konfiguriert. Für diesen Post wird nur eine simple Konfiguration verwendet.
1openapi-processor-mapping: v2 2 3options: 4 package-name: de.codecentric.generated.news 5 6map: 7 result: org.springframework.http.ResponseEntity 8 9 types: 10 - type: array => java.util.List
Um die Konvertierung ein erstes Mal zu starten, reicht gradle clean compileJava
. Nun stehen die Modelle und das Interface im Package de.codecentric.generated.news
zur Verfügung. Um nicht direkt auf das API-Modell der Artikel-Entität zuzugreifen, empfiehlt es sich, eine separate Entität zu erstellen. Es kann auch sein, dass die Entität nicht 1:1 dem API-Modell entspricht. Im Beispiel enthält die Artikel-Entität ein weiteres Attribut Author, das nicht über API nach außen gegeben wird. Um ein Mapping zwischen der Entität und dem Model herzustellen, wird eine entsprechende Mapper-Klasse erstellt.
1package de.codecentric.apifirstspringboot.mapper;
2
3import com.fasterxml.jackson.core.JsonProcessingException;
4import com.fasterxml.jackson.databind.ObjectMapper;
5import de.codecentric.apifirstspringboot.entities.Article;
6
7import java.util.List;
8import java.util.stream.Collectors;
9
10public class ArticleModelMapper {
11
12 private static ObjectMapper objectMapper = new ObjectMapper();
13
14 public static Article toEntity(de.codecentric.news.api.model.Article article) {
15 return Article.builder()
16 .description(article.getDescription())
17 .title(article.getTitle())
18 .date(article.getDate())
19 .imageUrl(article.getImageUrl())
20 .build();
21 }
22
23 public static de.codecentric.news.api.model.Article toApi(Article article) {
24 de.codecentric.news.api.model.Article articleModel = new de.codecentric.news.api.model.Article();
25 articleModel.setId(article.getId());
26 articleModel.setTitle(article.getTitle());
27 articleModel.setDate(article.getDate());
28 articleModel.setDescription(article.getDescription());
29 articleModel.setImageUrl(article.getImageUrl());
30
31 return articleModel;
32 }
33
34 public static List<de.codecentric.news.api.model.Article> toApi(List<Article> retrieveAllArticles) {
35 return retrieveAllArticles.stream()
36 .map(ArticleModelMapper::toApi)
37 .collect(Collectors.toList());
38 }
39
40 public static de.codecentric.news.api.model.Article jsonToArticle(String json) throws JsonProcessingException {
41 return objectMapper.readValue(json, de.codecentric.news.api.model.Article.class);
42 }
43}
Ebenso kann auch ein Framework wie Mapstruct für das Mapping zum Einsatz kommen.
Um nun ein API über den Service nach außen zur Verfügung zu stellen, wird noch eine Repository- und eine Controller-Klasse benötigt.
1package de.codecentric.apifirstspringboot.repository; 2 3import de.codecentric.apifirstspringboot.entities.Article; 4import org.springframework.data.jpa.repository.JpaRepository; 5 6public interface NewsRepository extends JpaRepository<Article, Long> { 7 8}
Der Controller wird das generierte API Interface implementieren.
1package de.codecentric.apifirstspringboot.controller;
2
3import de.codecentric.apifirstspringboot.service.NewsService;
4import de.codecentric.generated.news.api.NewsApi;
5import de.codecentric.generated.news.model.Article;
6import org.springframework.http.ResponseEntity;
7import org.springframework.stereotype.Controller;
8import org.springframework.web.bind.annotation.RequestMapping;
9
10import java.util.List;
11
12import static de.codecentric.apifirstspringboot.mapper.ArticleModelMapper.toApi;
13
14@Controller
15@RequestMapping("/api")
16public class NewsController implements NewsApi {
17
18 private NewsService newsService;
19
20 public NewsController(NewsService newsService) {
21 this.newsService = newsService;
22 }
23
24 @Override
25 public ResponseEntity<List<Article>> getNews() {
26 List<de.codecentric.apifirstspringboot.entities.Article> allArticles = newsService.retrieveAllArticles();
27 return ResponseEntity.ok().body(toApi(allArticles));
28 }
29}
Auf Basis von schema.sql
und data.sql
liefert der Endpunkt /api/news
folgenden JSON-Response zurück.
1[ 2 { 3 "date": "2021-07-08", 4 "description": "codecentric is...", 5 "id": 1, 6 "imageUrl": "http://picserve.codecentric.de/1/bild", 7 "title": "Company news" 8 } 9]
Fallstricke: OpenAPI Spec
Zum Schluss möchte ich noch auf zwei Fallstricke eingehen, die im Laufe der Entwicklung aufgetaucht sind. Zuallererst sollte aufgefallen sein, dass nicht die neuste OpenAPI-Spezifikation verwendet wird. Dies liegt darin begründet, dass die Parser im Java-Umfeld (swagger und openapi4j) nur OpenAPI Spec 3.0 (genauer 3.0.3) unterstützen. Dies betrifft dann auch andere Code-Generatoren, wie den OpenAPI-Generator.
Wenn man sich für die Entwicklung auf Basis von API first entscheidet, ist es wichtig im Auge zu behalten, welche Version der Spezifikation schon von den Generatoren unterstützt wird.
In der OpenAPI Spec enthält die URL des Servers auch den Basispfad (/api
). Das Feld servers.url
wird vom OpenAPI Processor nicht ausgelesen. Hier gibt es nun zwei Lösungsvarianten. Entweder wird der Basispfad immer bei den paths
hinterlegt oder dieser muss im Controller für das API als Annotation per Hand hinterlegt werden. Im Repo findet sich die zweite Variante wieder.
Zusammenfassung
Es ist festzustellen, dass es mit Einschränkungen möglich ist, auf Basis der Idee von API first Services mit Spring Boot zu entwickeln. Wenn man sich der aktuellen Fallstricke auch bewusst ist, wird es gelingen, Services in entsprechender Geschwindigkeit und Güte verfügbar zu machen.
Weitere Beiträge
von Daniel Kocot
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog-Autor*in
Daniel Kocot
Senior Solution Architect / Head of API Consulting
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.