Документируем и тестируем REST API с помощью SpringRestDocs

в 12:02, , рубрики: api, java, rest java spring springrestdocs api

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

С момента знакомства с этим инструментом я понял что появилось решение, которого я ждал и его не хватало в разработке. Посудите сами — об этом можно было только мечтать:

  • Документация генерируется автоматически при запуске тестов.
  • Можно управлять исходным форматом файла документации — например скомпилировать html. Поскольку мы пользуемся Spring boot, то можно модифицировать шаги и задачи в gradle, копировать файл документации и включать его в jar-ку, делать паблишин документации на удаленный сервер, копировать документацию в архив. Таким образом у Вас всегда будет статический эндпоинт с документацией где бы не был задеплоен Ваш сервис. Для офлайн версии можно подключить pdf, epub, abook разрешения.
  • Документация нашего REST сервиса гарантировано соответствует логике работы. Документация синхронизирована с логикой работы приложения. Сделали изменения, забыли отразить их в документации — мгновенно видите падающие тесты с детальным описание разницы несоответствия.
  • Документация генерируется из тестов. Теперь для того чтобы добавить в документацию новые секции либо начать вести её — нужно начать с написания теста, да именно теста. Ведь очень часто разработчики, в условиях нехватки времени, непоставленных процессов на проекте, либо прочих причин пишут тонны кода, но совершенно не обращают внимание на важность тестов. Как ни странно но фреймворк для документирования стимулирует Вас работать по TDD
  • Как следствие, Вы поддерживаете coverege на высоком уровне. Точнее важны не проценты покрытия, которые будут рисоваться в системах анализа кода либо отчетах. Важно именно то, что Вы будете покрывать отдельными тестами различные сценарии и включать их результаты выполнения в документацию. Зеленые тесты — это всегда приятно.

Давайте разберёмся с работой SpringRestDocs, я буду комбинировать материал теоретическими вставками и вести практическую линию туториала, ознакомившись с которым можно настроить и использовать фреймворк.

SpringRestDocs Pipeline

Для того чтобы начать работать с SpringRestDocs, необходимо разобраться с принципом работы его пайплайна, он достаточно простой и линейный:

rest docs pipeline

Все действия происходят из тестов, кроме логики верификации ресурсов, также происходит генерация снипетов. Снипеты представляют из себя сериализированное значение определенного HTTP атрибута, с которым взаимодействовал наш контроллер. Мы подготавливаем специальный файл шаблон, в котором указываем, в какие секции должны включаться сгенерированные снипеты. На выходе получается компилированный файл документации, обратите внимание что мы можем задовать формат документации — он может быть в формате html, pdf, epub, abook.

Далее по тексту статьи мы соберем этот пайплайн, напишем тесты, настроим SpringRestDocs и скомпилим документацию.

Зависимости

Ниже приведу зависимости, из моего проекта работающего со spring rest docs, на примере которого будем разбирать работу

dependencies {
    compile "org.springframework.boot:spring-boot-starter-data-jpa"
    compile "org.springframework.boot:spring-boot-starter-hateoas"
    compile "org.springframework.boot:spring-boot-starter-web"
    compile "org.springframework.restdocs:spring-restdocs-core:$restdocsVersion"
    compile "com.h2database:h2:$h2Version"
    compile "org.projectlombok:lombok"

    testCompile "org.springframework.boot:spring-boot-starter-test"
    asciidoctor "org.springframework.restdocs:spring-restdocs-asciidoctor:$restdocsVersion"
    testCompile "org.springframework.restdocs:spring-restdocs-mockmvc:$restdocsVersion"
    testCompile "com.jayway.jsonpath:json-path"
}

Тестируемый Контроллер

Покажу часть контроллера, для которого мы напишем тест, подключим SpringRestDocs и сгенерим документацию.

@RestController
@RequestMapping("/speakers")
public class SpeakerController {

    @Autowired
    private SpeakerRepository speakerRepository;

    @GetMapping(path = "/{id}")
    public ResponseEntity<SpeakerResource> getSpeaker(@PathVariable long id) {
        return speakerRepository.findOne(id)
                .map(speaker -> ResponseEntity.ok(new SpeakerResource(speaker)))
                .orElse(new ResponseEntity(HttpStatus.NOT_FOUND));
    }

Давайте разберём его логику. С помощью SpringDataRepository я обращаюсь в БД за записью с ID, которая была передана в контроллер. SpringDataRepository возвращает Optional — в случае, если в нем присутствует значение мы выполняем трансформацию JPA Entity в ресурс (при этом мы можем энкапсулировать часть полей, которые мы не хотим показывать в респонсе), если же Optional.isEmpty() то мы возвращаем 404 код NOT_FOUND.

Код ресурса SpeakerResource

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Relation(value = "speaker", collectionRelation = "speakers")
public class SpeakerResource extends ResourceSupport {

    private String name;
    private String company;

    public SpeakerResource(Speaker speaker) {
        this.name = speaker.getName();
        this.company = speaker.getCompany();

        add(linkTo(methodOn(SpeakerController.class).getSpeaker(speaker.getId())).withSelfRel());
        add(linkTo(methodOn(SpeakerController.class).getSpeakerTopics(speaker.getId())).withRel("topics"));
    }
}

Напишем базовый тест для этого эндпоинта

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs(outputDir = "build/generated-snippets")
public class SpControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private SpeakerRepository speakerRepository;

    @After
    public void tearDown() {
        speakerRepository.deleteAll();
    }

    @Test
    public void testGetSpeaker() throws Exception {
        // Given
        Speaker speaker = Speaker.builder().name("Roman").company("Lohika").build();
        speakerRepository.save(speaker);

        // When
        ResultActions resultActions = mockMvc.perform(get("/speakers/{id}", speaker.getId()))
                .andDo(print());

        // Then
        resultActions.andExpect(status().isOk())
                .andExpect(jsonPath("name", is("Roman")))
                .andExpect(jsonPath("company", is("Lohika")));
    }

}

В тесте я подключаю автоконфигурацию mockMVC, RestDocs. Для restdocs необходимо обязательно указать директорию, куда будут генериться снипеты (outputDir = «buid/generated-snippets»), это обычный тест с использование mockMvc, который мы практически каждый день пишем когда тестируем рест сервисы. Я использую проприетарную библиотеку из депенденси spring.tests mockMvc, однако если Вы предпочитаете использовать RestAssured, то все прочитанное также будет актуально — есть лишь небольшие модификации. Мой тест выполняет вызов HTTP метода контроллера, делает верификацию статуса, полей и распечатывает в консоль request/response flow.

ResultsHandler

После запуска теста в его выводе мы видим следующее:

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /speakers/1
       Parameters = {}
          Headers = {}

Handler:
             Type = smartjava.domain.speaker.SpeakerController
           Method = public org.springframework.http.ResponseEntity<smartjava.domain.speaker.SpeakerResource> smartjava.domain.speaker.SpeakerController.getSpeaker(long)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = {Content-Type=[application/hal+json;charset=UTF-8]}
     Content type = application/hal+json;charset=UTF-8
             Body = {
  "name" : "Roman",
  "company" : "Lohika",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/speakers/1"
    },
    "topics" : {
      "href" : "http://localhost:8080/speakers/1/topics"
    }
  }
}

Это вывод в консоль содержимого HTTP request и response. Таким образом мы можем проследить какие значения были переданы в наш контроллер и какой был респонс из него. Вывод в консоль выполняет подключенный хендлер:

 resultActions.andDo(print());

ResultHandler представляет из себя функциональный интерфейс. Создав свою реализацией и подключив её в тесте, мы можем иметь доступ к HttpRequest/HttpResponse, который выполнялся в тесте и интерпретировать результаты выполнения, при желании делать запись этих значений в консоль, на файловую систему, в собственный файл документации и прочее.

public interface ResultHandler {

	/**
	 * Perform an action on the given result.
	 *
	 * @param result the result of the executed request
	 * @throws Exception if a failure occurs
	 */
	void handle(MvcResult result) throws Exception;

}

MvcResult

Как мы видим ResultHandler имеет доступ и может интерпретировать значениями MvcResult — объекту содержащему рещультаты выполнения mockMvc теста, а через него к атрибутам двух ключевых игроков — MockHttpServletRequest, MockHttpServletResponse. Вот неполный список этих атрибутов:

mvcResults

Вот в качестве примера MyResultHandler, который логирует тип вызванного HTTP метода и статус код респонса:

public class MyResultHandler implements ResultHandler {
    private Logger logger = LoggerFactory.getLogger(MyResultHandler.class);


    static public ResultHandler myHandler() {
        return new MyResultHandler();
    }

    @Override
    public void handle(MvcResult result) throws Exception {
        MockHttpServletRequest request = result.getRequest();
        MockHttpServletResponse response = result.getResponse();
        logger.error("HTTP method: {}, status code: {}", request.getMethod(), response.getStatus());
    }
}

 resultActions.andDo(new MyResultHandler())

Именно эту идею с обработкой и регистрацией использовал Pivotal для генерации документации. Нам необходимо в наш тест подключить хендлер из класса MockMvcRestDocumentation:

// Document
        resultActions.andDo(MockMvcRestDocumentation.document("{class-name}/{method-name}"));

Давайте генерить snippets

Запустим снова тест и обратим внимание на то, что после его выполнения в директории build/generated-snippets создались папки с файлами:

./sp-controller-test/test-get-speaker:
total 48
-rw-r--r--  1 rtsypuk  staff    68B Oct 31 14:17 curl-request.adoc
-rw-r--r--  1 rtsypuk  staff    87B Oct 31 14:17 http-request.adoc
-rw-r--r--  1 rtsypuk  staff   345B Oct 31 14:17 http-response.adoc
-rw-r--r--  1 rtsypuk  staff    69B Oct 31 14:17 httpie-request.adoc
-rw-r--r--  1 rtsypuk  staff    36B Oct 31 14:17 request-body.adoc
-rw-r--r--  1 rtsypuk  staff   254B Oct 31 14:17 response-body.adoc

Это и есть сгенерированные snippets. По умолчанию rest docs генерирует 6 типов снипетов, я покажу часть из них.

Snippet представляет из себя сериализированную в файле в текстовом представлении часть составляющей HTTP request/response нагрузки. Наиболее часто используемые сниппеты — curl-request, http-request, http-response, request-body, response-body, links (для HATEOAS сервисов), path-parameters, response-fields, headers.

curl-request.adoc

[source,bash]
----
$ curl 'http://localhost:8080/speakers/1' -i
----

http-request.adoc

[source,bash]
[source,http,options="nowrap"]
----
GET /speakers/1 HTTP/1.1
Host: localhost:8080

----

http-response.adoc

[source,bash]
[source,http,options="nowrap"]
----
HTTP/1.1 200 OK
Content-Type: application/hal+json;charset=UTF-8
Content-Length: 218

{
  "name" : "Roman",
  "company" : "Lohika",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/speakers/1"
    },
    "topics" : {
      "href" : "http://localhost:8080/speakers/1/topics"
    }
  }
}
----

Готовим файл шаблона

Теперь необходимо подготовить файл шаблона и разметить его секции, куда будут включаться сгенерированные блоки снипетов. Шаблон ведется в гибком формате asciidoc, по умолчанию шаблон должен находится в директории src/docs/asciidoc:

== Rest convention
include::etc/rest_conv.adoc[]
== Endpoints
=== Speaker
==== Get speaker by ID
===== Curl example
include::{snippets}/sp-controller-test/test-get-speaker/curl-request.adoc[]
===== HTTP Request
include::{snippets}/sp-controller-test/test-get-speaker/http-request.adoc[]
===== HTTP Response
====== Success HTTP responses
include::{snippets}/sp-controller-test/test-get-speaker/http-response.adoc[]
====== Response fields
include::{snippets}/sp-controller-test/test-get-speaker/response-fields.adoc[]
====== HATEOAS links
include::{snippets}/sp-controller-test/test-get-speaker/links.adoc[]

Используя синтаксис asciidoc мы может подключать статические файлы (например в файле rest_conv.adoc я сделал описание какие методы поддерживает сервис, в каких случаях какие статус коды должны возвращаться), а также автосгенерированные файлы snippets.

Статический rest_conv.adoc

=== HTTP verbs
Speakers Service tries to adhere as closely as possible to standard HTTP and REST conventions in its
use of HTTP verbs.
|===
| Verb | Usage
| `GET`
| Used to retrieve a resource
| `POST`
| Used to create a new resource
| `PATCH`
| Used to update an existing resource, including partial updates
| `PUT`
| Used to update an existing resource, full updates only
| `DELETE`
| Used to delete an existing resource
|===

=== HTTP status codes
Speakers Service tries to adhere as closely as possible to standard HTTP and REST conventions in its
use of HTTP status codes.

|===
| Status code | Usage

| `200 OK`
| Standard response for successful HTTP requests.
 The actual response will depend on the request method used.
 In a GET request, the response will contain an entity corresponding to the requested resource.
 In a POST request, the response will contain an entity describing or containing the result of the action.

| `201 Created`
| The request has been fulfilled and resulted in a new resource being created.

| `204 No Content`
| The server successfully processed the request, but is not returning any content.

| `400 Bad Request`
| The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).

| `404 Not Found`
| The requested resource could not be found but may be available again in the future. Subsequent requests by the client are permissible.

| `409 Conflict`
| The request could not be completed due to a conflict with the current state of the target resource.

| `422 Unprocessable Entity`
| Validation error has happened due to processing the posted entity.

|===

Конфигурируем build.gradle

Для того чтобы документация компилировалась необходимо сделать базовую настройку — подключить необходимые зависимости, в gradle.build buildscript.dependencies необходимо добавить asciidoctor-gradle-plugin

buildscript {
    repositories {
        jcenter()
        mavenCentral()
        mavenLocal()
        maven { url "https://plugins.gradle.org/m2/" }
    }
    dependencies {
        classpath "org.asciidoctor:asciidoctor-gradle-plugin:$asciiDoctorPluginVersion"
        classpath "org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion"
    }
}

Применим плагин

apply plugin: 'org.asciidoctor.convert'

Теперь нам необходимо сделать базовую конфигурацию asciidoctor:

asciidoctor {
    dependsOn test
    backends = ['html5']
    options doctype: 'book'

    attributes = [
            'source-highlighter': 'highlightjs',
            'imagesdir'         : './images',
            'toc'               : 'left',
            'toclevels'         : 3,
            'numbered'          : '',
            'icons'             : 'font',
            'setanchors'        : '',
            'idprefix'          : '',
            'idseparator'       : '-',
            'docinfo1'          : '',
            'safe-mode-unsafe'  : '',
            'allow-uri-read'    : '',
            'snippets'          : snippetsDir,
            linkattrs           : true,
            encoding            : 'utf-8'
    ]

    inputs.dir snippetsDir
    outputDir "build/asciidoc"
    sourceDir 'src/docs/asciidoc'
    sources {
        include 'index.adoc'
    }
}

Проверим сборку документации, запустим в консоли

gradle asciidoctor

поскольку мы указали, что задача asciidoctor зависит от запуска тестов, то сначала проранятся тесты, сгенерируют снипеты и эти снипеты будут включены в сгенерированную документацию.

Документация

Все описанные действия по настройке необходимо произвести один раз при поднятии проекта. Теперь каждый раз при запуске тестов у нас будут дополнительно генерироваться сниппеты и формироваться документация. Привожу несколько скринов:

Секция соглашения по HTTP методам и статус кодам

image

Пример документации метода Get All Speakers

image

Идентичная документация доступна и в формате pdf. Она удобна как офлайн версия, может быть выслана вместе со спецификациями Вашего сервиса клиентам.

image

Модификация задачи jar

Ну и поскольку мы работаем со spring boot теперь мы можем воспользоваться одним из его интересных свойств — все ресурсы которые находятся в директории src/static либо src/public будут доступны как статический контент при обращении из браузера

jar {
    dependsOn asciidoctor
    from ("${asciidoctor.outputDir}/html5") {
        include '**/index.html'
        include '**/images/*'
        into 'static/docs'
    }
}

Именно это мы и сделаем — после сборки документации будем копировать её в директорию /static/docs. Таким образом каждый собраный артифакт jar будет содержать статический эндоинт с документацией. Независимо от того, где он будет задеплоен, на каком окружении будет находится — перед нами всегда будет доступна актуальная версия документации.

Заключение

Это только малая часть возможностей этого замечательного инструмента, невозможно всё осветить в одной статье. Всем, кто заинтересовался SpringRestDocs, предлагаю ссылки на ресурсы:

  • так выглядит скомпилированная документация, на этом примере можно посмотреть на asciidoc формат, насколько это мощный инструмент (кстати доку можно автоматизированно аплоудить на githubpages) tsypuk.github.io/springrestdoc
  • мой github с настроеным демо-проектом с SpringRestDocs github.com/tsypuk/springrestdoc (все сконфигурино, код используйте в Ваших проектах для быстрого старта, здесь же есть демо синтаксиса asciidoctor, примеры расширений, диаграмм, которые можно легко генерировать и включать в документацию)
  • Ну и конечно официальная документация projects.spring.io/spring-restdocs

Автор: rtsypuk

Источник

Поделиться

* - обязательные к заполнению поля