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"
}
}
Specyfikacja 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.
Sekcja 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
Security
Kończąc naszą specyfikację, 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/