API-first-Services mit Spring Boot

2 Kommentare

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:

openapi: 3.0.3
servers:
  - url: 'http://localhost:8080/api'
info:
  version: 1.0.0
  title: News API
  contact:
    name: Daniel Kocot
    url: 'http://www.codecentric.de'
    email: daniel.kocot@codecentric.de
  license:
    name: MIT
    url: https://www.tldrelgal.com/mit
  description: An API to provide news
tags:
  - name: news
paths:
  /news:
    get:
      description: gets latest news
      operationId: getNews
      tags:
        - news
      responses:
        '200':
          description: Expected response to a valid request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ArticleList'
              examples: {}
        '404':
          description: Unexpected error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  schemas:
    ArticleList:
      title: ArticleList
      type: array
      items:
        $ref: '#/components/schemas/Article'
    Article:
      title: Article
      description: A article is a part of a news.
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        date:
          type: string
          format: date
        description:
          type: string
        imageUrl:
          type: string
      required:
        - id
        - title
        - date
        - description
        - imageUrl
    Error:
        type: object
        properties:
          code:
            type: string
          messages:
            type: string
        required:
          - code
          - message
  securitySchemes: {}
  examples:
    news:
      value:
        description: Example shared example
        type: object
        properties:
          id:
            type: string
        required:
          - 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:

plugins {
    id 'org.springframework.boot' version '2.5.2'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'de.undercouch.download' version '4.1.2'
    id 'io.openapiprocessor.openapi-processor' version '2021.3'
    id 'java'
}

group = 'de.codecentric'
version = '0.0.2-SNAPSHOT'
sourceCompatibility = '11'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-data-rest'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.h2database:h2'

    testImplementation('org.springframework.boot:spring-boot-starter-test')
    testImplementation('org.junit.jupiter:junit-jupiter-api:5.7.2')
    testRuntime('org.junit.jupiter:junit-jupiter-engine:5.7.2')
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}

test {
    useJUnitPlatform()
}

task downloadFile(type: Download) {
    src "https://raw.githubusercontent.com/codecentric/api-showcases/main/specs/news.yaml"
    dest "${projectDir}/src/api"
    onlyIfModified true
    useETag true
}

openapiProcessor {
    spring {
        processor 'io.openapiprocessor:openapi-processor-spring:2021.5'
        apiPath "$projectDir/src/api/news.yaml"
        targetDir "$projectDir/build/openapi"
        mapping "$projectDir/mapping.yaml"
        showWarnings true
    }
}

afterEvaluate {
    tasks.processSpring.dependsOn(tasks.downloadFile)
}

sourceSets {
    main {
        java {
            srcDir "build/openapi"
        }
    }

    test {
        resources {
            srcDir file('src/test/java')
            exclude '**/*.java'
        }
    }
}

compileJava.dependsOn('processSpring')

springBoot {
    mainClassName = "de.codecentric.apifirstspringboot.ApifirstSpringbootApplication"
}

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.

openapi-processor-mapping: v2

options:
  package-name: de.codecentric.generated.news

map:
  result: org.springframework.http.ResponseEntity

  types:
    - 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.

package de.codecentric.apifirstspringboot.mapper;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.codecentric.apifirstspringboot.entities.Article;

import java.util.List;
import java.util.stream.Collectors;

public class ArticleModelMapper {

    private static ObjectMapper objectMapper = new ObjectMapper();

    public static Article toEntity(de.codecentric.news.api.model.Article article) {
        return Article.builder()
                .description(article.getDescription())
                .title(article.getTitle())
                .date(article.getDate())
                .imageUrl(article.getImageUrl())
                .build();
    }

    public static de.codecentric.news.api.model.Article toApi(Article article) {
        de.codecentric.news.api.model.Article articleModel = new de.codecentric.news.api.model.Article();
        articleModel.setId(article.getId());
        articleModel.setTitle(article.getTitle());
        articleModel.setDate(article.getDate());
        articleModel.setDescription(article.getDescription());
        articleModel.setImageUrl(article.getImageUrl());

        return articleModel;
    }

    public static List<de.codecentric.news.api.model.Article> toApi(List<Article> retrieveAllArticles) {
        return retrieveAllArticles.stream()
                .map(ArticleModelMapper::toApi)
                .collect(Collectors.toList());
    }

    public static de.codecentric.news.api.model.Article jsonToArticle(String json) throws JsonProcessingException {
        return objectMapper.readValue(json, de.codecentric.news.api.model.Article.class);
    }
}

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.

package de.codecentric.apifirstspringboot.repository;

import de.codecentric.apifirstspringboot.entities.Article;
import org.springframework.data.jpa.repository.JpaRepository;

public interface NewsRepository extends JpaRepository<Article, Long> {

}

Der Controller wird das generierte API Interface implementieren.

package de.codecentric.apifirstspringboot.controller;

import de.codecentric.apifirstspringboot.service.NewsService;
import de.codecentric.generated.news.api.NewsApi;
import de.codecentric.generated.news.model.Article;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.List;

import static de.codecentric.apifirstspringboot.mapper.ArticleModelMapper.toApi;

@Controller
@RequestMapping("/api")
public class NewsController implements NewsApi {

    private NewsService newsService;

    public NewsController(NewsService newsService) {
        this.newsService = newsService;
    }

    @Override
    public ResponseEntity<List<Article>> getNews() {
        List<de.codecentric.apifirstspringboot.entities.Article> allArticles = newsService.retrieveAllArticles();
        return ResponseEntity.ok().body(toApi(allArticles));
    }
}

Auf Basis von schema.sql und data.sql liefert der Endpunkt /api/news folgenden JSON-Response zurück.

[
    {
        "date": "2021-07-08",
        "description": "codecentric is...",
        "id": 1,
        "imageUrl": "http://picserve.codecentric.de/1/bild",
        "title": "Company news"
    }
]

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.

Seit Oktober 2016 ist Daniel Teil des Teams der codecentric in Solingen. Zu Anfang als Consultant mit dem Schwerpunkt auf Application Lifecycle Management, verlagerte sich sein Schwerpunkt immer mehr in Richtung APIs. Neben zahlreichen Kundenprojekten und seinem Engagement in der Open Source-Welt rund um APIs, ist er auch als API-Expert ein gefragter Referent.

Über 1.000 Abonnenten sind up to date!

Die neuesten Tipps, Tricks, Tools und Technologien.
Jede Woche direkt in deine Inbox.

Kostenfrei anmelden und immer auf dem neuesten Stand bleiben!
(Keine Sorge, du kannst dich jederzeit abmelden.)

Kommentare

  • Jonas Gröger

    7. Oktober 2021 von Jonas Gröger

    Unfortunately the previous maintainer will no longer maintain OpenAPI4j, since (my guess) the changes with 3.1. were too much.

    The project https://github.com/openapi4j/openapi4j is now marked as „archived“ with a statement:

    > This repository is now archived. I don’t have enough spare time to maintain this project (well actually revamp) and follow OAI specs. This project deserves much more that I can give to source code and followers to provide appropriate output.

    If you use 3.0.x it’ll still work however 🙂

    • Daniel Kocot

      18. Oktober 2021 von Daniel Kocot

      Thanks Jonas for the comment. With OpenAPI 3.1 the parser like OpenAPI4J need a general redesign. So a pure API first approach with client/server stub generation using OpenAPI 3.1 isn’t possible at the moment. But OpenAPI 3.0 might a good starting point to use a specification for services in general ;).

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.