Dokumentacja oraz generacja aplikacji REST Spring przy użyciu Openapi

W tym artykule chciałbym przedstawić generację aplikacji REST przy użyciu specyfikacji OpenAPI, Springa, Swaggera oraz Gradle’a.
OpenAPI w wersji 3.0, jest formatem opisu API dla REST API. Plik OpenApi (w formacie yaml/yml albo json) umożliwia opis całego projektowanego API.
Do wygenerowania API wykorzystamy następujące technologie:
- Spring w wersji 2.3.2.RELEASE. Dla pracy przy innych wersjach należy zwrócić szczególną uwagę na kompatybilność bibliotek Swaggera z wersją Springa. Podana konfiguracja będzie poprawnie generowała pliki UI jak i kod aplikacji, jednakże nie udostępni automatycznie interfejsu graficznego.
- Swaggger, narzędzie open-source, odpowiedzialne za budowanie dokumentacji oraz klas. Będziemy wykorzystywać wbudowane narzędzia, SwaggerUI oraz SwaggerCodegen.
- Gradle w wersji 7.4, narzędzie do automatycznego budowania oprogramowania. Wykorzystuje język DSL (Domain Specific Language).
Konfiguracja: W pliku build.gradle potrzebujemy zdefiniować następujące elementy:
Plugin odpowiedzialny za generację kodów:
plugins {
...
id 'org.hidetake.swagger.generator' version '2.19.2'
}
Następnie dodajemy do dependency brakujące zależności:
dependencies {
...
swaggerCodegen 'org.openapitools:openapi-generator-cli:3.3.4'
swaggerUI 'org.webjars:swagger-ui:3.52.5'
implementation "io.springfox:springfox-swagger2:2.9.2"
}
Dependency nie musi być w pliku build.gradle. Zazwyczaj jest wydzielane do osobnego pliku.
Definicje zadania generacji kodów:
swaggerSources {
main {
inputFile = file('main.yaml')
code {
language = 'spring'
components = [
'models',
'apis'
]
configFile = file('main.json')
}
ui {
doLast {
copy {
from 'index.html'
into outputDir
}
}
}
}
}
InputFile- wskazuje na plik OpenAPI na podstawie którego zostanie wygenerowany kod.
Podzadanie code - określa konfigurację dla generatora kodu aplikacji. Przykładowa konfiguracja z pliku main.json:
{
"dateLibrary": "java11",
"delegatePattern": true,
"library": "spring-boot",
"apiPackage": "pl.main.api",
"modelPackage": "pl.main.model",
"invokerPackage": "pl.main",
"systemProperties": {
"supportingFiles": "ApiUtil.java"
}
}
Definiujemy nazwy pakietów w których zostaną wygenerowane kody. Z istotniejszych konfiguracji wykorzystamy "delegatePattern": true.
Podzadanie ui - określa konfigurację dla generatora interfejsu użytkownika aplikacji (dokumentacji)
Człon “main” odpowiada za generacje kodów dla jednej “aplikacji” opisanej w pliku. OpenAPI. W przypadku konieczności wygenerowania kodów innych aplikacji (dla przykładu klientów REST), trzon main powinien zostać powtórzony ze zmienioną nazwą, wraz z odpowiednią konfiguracją:
swaggerSources {
main {
...
}
client {
...
}
Aby wygenerowane pliki były widoczne w źródłach, musimy je dodać poprzez:
sourceSets {
main {
java {
srcDirs = ['src/main/java', "${swaggerSources.main.code.outputDir}/src/main/java"]
}
resources {
srcDirs = ['src/main/resources', "${swaggerSources.main.code.outputDir}/src/main/resources"]
}
}
}
Kończąc naszą konfigurację, możemy wymusić odpalenie zadań generacji kodu pod task build:
build.dependsOn swaggerSources.main.code, swaggerSources.main.ui
Następnie dodajemy wygenerowany folder do statycznych zasobów w czasie odpalania aplikacji bootRun lub bootJar. Bez tego dokumentacja nie zostanie udostępniona na natywnym porcie.
bootRun {
dependsOn "generateSwaggerUIMain"
systemProperty "spring.resources.static-locations", "file:build/swagger-ui-main"
}
bootJar {
dependsOn "generateSwaggerUIMain"
from("${projectDir}/build/swagger-ui-main") {
into "BOOT-INF/classes/static"
}
}
Przy tak skonfigurowanej aplikacji możemy przejść do omawiania specyfikacji OpenAPI.
Poprawnie napisana specyfikacja umożliwia łatwe wprowadzanie zmian w strukturze aplikacji. Czystą, łatwą do zrozumienia dokumentację. Znacząco przyspiesza pracę, poprzez generowanie gotowego kodu, na podstawie krótkich definicji.
W tym przypadku będę używał pliku yaml. W nagłówku pliku znajdziemy OpenAPI wraz z wersją i informacją o produkcie, na którą składa się: wersja aplikacji, tytuł aplikacji, opis. W niższych linijkach definiujemy serwer API. Zazwyczaj definiowany jest jeden serwer, w naszym przypadku zdefiniowałem dwa. Wszystkie ścieżki opisane w endpointach będą relatywne wobec adresu serwerów.
openapi: 3.0.0
info:
version: 1.0.0
title: Main API
description: Testowa aplikacja prezentująca OpenAPI
servers:
- url: localhost/api/v1
description: Adres do wywołań lokalnych
- url: main.com
description: Inny adres do wywołań zewnętrznych
Dany początek pliku będzie skutkował następującą generacją UI:
Przykładowa definicja endpointu będzie wyglądała następująco:
paths:
'/user/{uuid}':
get:
tags:
- Użytkownicy
operationId: getUser
summary: Pobranie użytkownika
description: |
Operacja zwraca dane użytkownika
parameters:
- in: path
name: uuid
required: true
schema:
description: UUID
type: string
responses:
200:
description: Pobrano użytkownika
content:
'application/json':
schema:
$ref: '#/components/schemas/User
404:
description: Nie znaleziono użytkownika
500:
description: Błąd servera
content:
'application/json':
schema:
$ref: '#/components/schemas/ErrorInfo'
Sekcja paths odpowiada za definiowanie wywołań aplikacji. Podajemy adres relatywny. Metodę pod jaką będzie dostępny dany endpoint (get). Znacznik tags pozwala nam pogrupować metody w podkatalogi, wpływa to na prezentację wywołań w dokumentacji oraz nazwę klasy w której zostanie wygenerowany kod. OperationId określa nazwę metody jaką będziemy musieli nadpisać, aby zaimplementować logikę. Sekcja parameters definiuje jakie parametry będziemy przyjmować. W tym wypadku wykorzystujemy parametr określony w ścieżce o nazwie uuid. Parametr można zdefiniować w tym miejscu, jednakże z powodów czytelności i powtarzalności lepiej jest go wydzielić do opisu modelu. Sekcja responses opisuje odpowiedzi. Podano możliwe zwracane statusy (200,400,500). Dla statusu 200 podajemy jaki obiekt będzie zwracany przy poprawnym wywołaniu. W tym przypadku będzie to model opisany w schematach “User”.
Istnieje możliwość definicji własnej odpowiedzi przy błędnym wywołaniu, tak samo jak w innych przypadkach wskazujemy na odpowiedni model. Pozostaje nam zdefiniować wykorzystywane modele w sekcji components:
components:
parameters:
uuid:
name: uuid
in: path
description: Unikalny identyfikator dokumentu
required: true
schema:
type: string
format: uuid
Parametry definiowane są w osobnej sekcji od modeli. Zdefiniowałem parametr UUID którego oczekujemy przy wywołaniu adresu “{server}/user/{uuid}”. Pominę dokładny opis parametrów. Jak widać, sam język jest dość intuicyjny i łatwo przyswajalny.
Definicja schematów następuje na tej samej “wysokości” co parametrów:
schemas:
User:
type: object
description: Użytkownik
required:
- login
properties:
login:
description: Login użytkownika
type: string
active:
description: True określa konto aktywne
type: boolean
additionalInformations:
type: array
items:
$ref: '#/components/schemas/AdditionalInformations'
ErrorInfo:
description: Informacja o błędzie
type: object
properties:
timestamp:
description: Data i czas utworzenia odpowiedzi
type: string
format: date-time
statusCode:
description: Kod statusu HTTP odpowiedzi
type: integer
message:
description: Opis błędu
type: string
W tym wypadku również nie ma co się rozpisywać nad definicjami opisanymi powyżej. Opisałem dla obiektu User trzy pola: login (string), active (boolean), additionalInformations (obiekt klasy additionalInformations opisany niżej)
additionalInformations:
type: object
description: Dodatkowe informacje
properties:
comment:
description: Komentarz użytkownika
type: string
Kończąc naszą specyfikacje, dodajmy jeszcze linijkę odpowiadającą za security.
Z powodów czytelności powinniśmy ją dodać na samej górze pliku, pod sekcją info:
security:
- jwtToken: []
Doda nam to w interfejsie przycisk do autoryzacji:
Umieszczenie sekcji security będzie skutkowało dołączeniem tokenu do wszystkich wywołań. W sytuacji gdy chcemy zabezpieczyć tylko wybrane endpointy, możemy ją umieścić w odpowiedniej sekcji paths. Zbudowanie aplikacji.
Tak opisaną i skonfigurowaną aplikację możemy poleceniem build zbudować. Otrzymamy wygenerowane kody w katalogu build:
W katalogu swagger-code-main przechowywane są wygenerowane klasy javy, zaś w katalogu swagger-ui-main znajduje się dokumentacja. Przy uruchomieniu aplikacji poprzez bootRun otrzymamy dostęp naszego interfejsu. W tym przypadku wchodząc na http://localhost:8080/, wyświetlona zostanie strona swaggera:
Na samym początku konfigurowania OpenAPI zdefiniowaliśmy delegatePattern na true. Zachęcam do tego podejścia. Skutkuje on wygenerowaniem innej struktury plików:
W tym patternie gdy chcemy zaimplementować logikę tworzymy klasę UserApiDelegateImp, która będzie implementacją UserApiDelegate, i w tej klasie przekazujemy logikę na napisane przez nas serwisy.
package pl.devtech.main.backend.delegate;
import org.springframework.http.ResponseEntity;
import pl.main.api.UserApiDelegate;
import pl.main.model.User;
public class UserApiDelegateImp implements UserApiDelegate {
@Override
public ResponseEntity<User> getUser(String uuid) {
return UserApiDelegate.super.getUser(uuid);
}
}
W ten prosty sposób poznaliśmy podstawowe możliwości wykorzystania OpenAPI w projekcie. Rozwinięcia możliwych typów danych, rozwinięć konfiguracji można znaleźć w dokumentacji pod adresem: swagger.io/docs/specification/about/