(Первоначальный автор этого сообщения в блоге: Роман Тучин)

Идея иметь документацию в виде кода не нова и была внедрена давно. Одним из пионеров был LaTeX. Описанный здесь подход — один из многих удобных способов документирования API, при котором вы можете свести к минимуму ручное написание документации. Spring REST Docs — отличный инструмент для этого.

Отказ от ответственности

В этом сообщении в блоге не рассматриваются основы использования Spring REST Docs. В Интернете вы можете найти несколько руководств по началу работы с ним, т.е. этот или этот. Цель этой записи в блоге — дать больше информации о расширенной функции, а именно — о создании пользовательских фрагментов документации, основанных на вашем коде. Например, фрагмент может содержать таблицу со всеми возможными значениями перечисления поля в полезной нагрузке запроса для RESTful API.

Технический стек

В качестве примеров технологий я выбрал следующие:

  • Язык программирования Котлин.
  • Фреймворк Ktor для реализации примера RESTful API
  • Фреймворк Koin для внедрения зависимостей
  • JUnit 5 для запуска тестов
  • REST Assured совместно с Spring REST Docs для генерации документации
  • Грэдл (Котлин) для строительства

Обзор проекта

Исходный код проекта можно найти здесь.

В качестве примера проекта я подготовил простой Contact API для создания контактов.
Он имеет одну конечную точку для создания контактов:

POST /contacts HTTP/1.1
{
  "contactKey" : "dc4b7722-5c47-4ee5-939e-6469b1f9d4e7",
  "type" : "END_USER"
}

который обрабатывает запрос и возвращает другую полезную нагрузку JSON:

HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
{
  "contactKey" : "8fa5c51c-cb3e-4cf3-a4b5-6730c99cb011"
}

Сначала я подготовил Ktor-Application:

fun main(args: Array<String>) {
    // Start Ktor
    embeddedServer(Netty, commandLineEnvironment(args)).start()
}
fun Application.main() {
    install(Koin) {
        modules(dependencies)
    }
    install(DefaultHeaders) {
    }
    install(CallLogging) {
        level = Level.DEBUG
    }
    install(ContentNegotiation) {
        jackson {
            // Configure Jackson's ObjectMapper here
        }
    }
    val createContactHandler: CreateContactHandler by inject()
    routing {
        post("/contacts/") {
            createContactHandler.handle(call)
        }
    }
}

Здесь вы видите определенный POST-маршрут /contacts/ для создания нового контакта. Вызов маршрута передается CreateContactHandler (введен Koin):

class CreateContactHandler {
    companion object : KLogging()
    suspend fun handle(call: ApplicationCall) {
        val request = call.receive(CreateContactRequest::class)
        logger.debug { "handling create contact request: $request" }
        //do handling
        val response = CreateContactResponse(UUID.randomUUID().toString())
        call.respond(response)
    }
}
data class CreateContactRequest(
    val contactKey: String,
    val type: ContactType
)
data class CreateContactResponse(
    val contactKey: String
)

Обработчик принимает входящий запрос, десериализует полезную нагрузку JSON, обрабатывает ее и отвечает другой полезной нагрузкой JSON.

Генерация документации

Для создания документации я использую следующий модульный тест:

@ExtendWith(RestDocumentationExtension::class)
class ContactApiDocTest {
    private val embeddedServer = embeddedServer(factory = Netty, port = 8080, module = Application::main)
        .apply { start() }
    lateinit var spec: RequestSpecification

    @BeforeEach
    fun setup(restDocumentation: RestDocumentationContextProvider) {
        this.spec = RequestSpecBuilder()
            .addFilter(
                RestAssuredRestDocumentation.documentationConfiguration(restDocumentation)
                    .operationPreprocessors()
                    .withRequestDefaults(
                        Preprocessors.prettyPrint()
                    )
                    .withResponseDefaults(
                        Preprocessors.prettyPrint()
                    )
                    .and()
            )
            .build()
    }
    @Test
    fun `should generate docs`() {
        given(this.spec)
            .header(HttpHeaders.CONTENT_TYPE, "application/json")
            .filter(document("create-contact"))
            .body(CreateContactRequest(contactKey = UUID.randomUUID().toString(), type = ContactType.END_USER))
            .`when`()
            .port(8080)
            .post("/contacts")
            .then()
            .assertThat()
            .statusCode(200)
            .header(HttpHeaders.CONTENT_TYPE, "application/json; charset=UTF-8")
    }
}

Код представляет собой типичный тест Spring REST Docs, который запускает встроенный веб-сервер на фиксированном порту 8080 и отправляет ему запрос. В реальном сценарии вы должны использовать случайный номер порта. Благодаря RestDocumentationExtension и соответствующей конфигурации в setup() стандартные сниппеты генерируются в папку build/generated snippets:

✔ 18:37 ~/projects/contactapi/build/generated-snippets/create-contact $ ll
total 56
Aug 12 14:17 .
Aug  9 17:28 ..
Aug 12 14:21 contact-types.adoc
Aug 12 14:21 curl-request.adoc
Aug 12 14:21 http-request.adoc
Aug 12 14:21 http-response.adoc
Aug 12 14:21 httpie-request.adoc
Aug 12 14:21 request-body.adoc
Aug 12 14:21 response-body.adoc

Позже вам нужно поместить файл index.adoc в ваш src/docs/asciidoc. Это ваша отправная точка для написания документов. Для начала добавьте следующий текст:

= Contact API
Kreuzwerker Author;
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:sectnums:
== Intro
Contact API is for managing of contacts.
=== Request
include::{snippets}/create-contact/curl-request.adoc[]
include::{snippets}/create-contact/http-request.adoc[]
=== Response
include::{snippets}/create-contact/response-body.adoc[]
include::{snippets}/create-contact/http-response.adoc[]

Как видите, я включил несколько фрагментов, созданных на предыдущих шагах.

Теперь вы можете начать генерацию документов с помощью gradle:

~/projects/contactapi [master] $ ./gradlew asciidoctor

После успешной генерации откройте его из build/asciidoc/html5/index.html. Это выглядит как:

Теперь у вас есть обзор стандартных функций Spring REST Docs, и мы можем продолжить…

Пользовательские фрагменты документов на основе вашего кода

Как вы видели выше, наш CreateContactRequest имеет поле типа ContactType. Класс перечисления ContactTypeis:

enum class ContactType {
    ADMIN,
    END_USER,
    TESTER
}

и я хотел бы задокументировать возможные значения поля, которые можно передать в качестве полезной нагрузки запроса. Скажем, в виде таблицы. Для этого вам следует создать собственный фрагмент для Spring REST Docs.

Первый шаг — добавить класс, расширяющий TemplatedSnippet:

class ContactTypesSnippet(snippetName: String?, attributes: MutableMap<String, Any>?)
    : TemplatedSnippet(snippetName, attributes) {
    companion object {
        const val SNIPPET_NAME = "contact-types"
    }
    constructor() : this(SNIPPET_NAME, null)
    override fun createModel(operation: Operation?): Map<String, Any> {
        return mapOf(
            "contactTypes" to ContactType.values()
        )
    }
}
fun contactTypesSnippet() = ContactTypesSnippet()

Вы видите, что в методе createModel вы добавляете массив всех значений из ContactTypeenum под именем contactTypes

Во-вторых, вы настраиваете дополнительный пользовательский фрагмент в модульном тесте с помощью
and().snippets().withAdditionalDefaults(contactTypesSnippet())

@BeforeEach
fun setup(restDocumentation: RestDocumentationContextProvider) {
    this.spec = RequestSpecBuilder()
        .addFilter(
            RestAssuredRestDocumentation.documentationConfiguration(restDocumentation)
                .operationPreprocessors()
                .withRequestDefaults(
                    Preprocessors.prettyPrint()
                )
                .withResponseDefaults(
                    Preprocessors.prettyPrint()
                )
                .and()
                .snippets().withAdditionalDefaults(contactTypesSnippet())
        )
        .build()
}

После этого необходимо добавить шаблон Mustache. Поместите под src/test/resources/org/springframework/restdocs/templates/asciidoctor/
следующий contact-types.snippet файл:

=== Contact types
|===
| Type name | Description
{{#contactTypes}}
| {{name}}
| some description
{{/contactTypes}}
|===

Вы видите новый раздел во фрагменте, который мы только что определили, и перебираете массив значений перечисления contactTypes.
Столбец Description является статическим, но он также может быть легко предоставлен перечислением.

Последний шаг, вам нужно включить этот фрагмент в свой index.adoc, добавив:

include::{snippets}/create-contact/contact-types.adoc[]

Теперь снова запустите $ ./gradle asciidoctor и в результате вы увидите следующий раздел в документах:

Вывод

Как вы видели, вы можете создавать отличную документацию, полностью полагаясь на свою кодовую базу, а это означает, что позже вам не нужно будет синхронизировать документы и код, и вы можете просто публиковать документы каждый раз, когда вы создаете свой проект (т.е. через Jenkins).

Кредиты на обложку идут по адресу: pexels.com