Предисловие

Данная статья может быть полезна тем, кто ищет способы автоматической генерации PDF-документации для описания API разработанного SpringBoot-приложения

Описание проблемы

При интеграции с нашим приложением, написанном на "классическом" SpringBoot-стэке встал вопрос о предоставлении описания API партнеру. Практически из коробки SpringBoot позволяет развернуть на стороне Вашего приложения тонкий Swagger-клиент и сгенерировать на лету спецификацию в формате Swagger (OpenAPI), которая представляет собой JSON особой структуры (хотя если читатель не знает, что это, наверное нет никакого смысла вообще читать эту статью).

Проблема осложнялась тем, что наш партнер разрабатывал на 1С, и во всех современных спецификациях для него были слишком сложно освоиться, поэтому встала задача предоставить документацию в человекориентированном виде - DOC, PDF и прочее. Я боюсь представить, в какой бы шок его повергла спецификация в формате WSDL (но это я уже отклоняюсь от темы).

В ходе изысканий в google, была найдена статья, и советы на stackoverflow, которые фактически повторяли эту статью. Фактически было найдено всего 2 варианта решения:

1) Использовать онлайн-конвертер

2) Настроить цепочку из 3 maven-плагинов:

  • swagger-maven-plugin генерирует swagger-спецификацию

  • swagger2markup-maven-plugin на основе swagger-спецификации генерирует документацию в формате ASCII-doctor

  • И уже asciidoctor-maven-plugin генерирует на основе ASCII-документации PDF-документацию

Для минимизации трудозатрат был выбран первый вариант - просто генерировать PDF-документацию в онлайн-конвертере. И на какое-то время показалось, что проблема решена.

Однако вскоре выяснилось, что онлайн конвертер корректно работает только со спецификацией в формате Swagger V2, которую генерируют наши legacy-сервисы, использующие для поддержки swagger'а библиотеку springfox. Большинство новых сервисов используют для этих целей библиотеку springdoc, которая уже генерирует спецификацию в формате Swagger V3 (он же OpenAPI 3). При попытке генерации PDF из спецификации этого формата, одноименный чекбокс на сайте онлайн-конвертора спасал мало - PDF-файл либо вообще не генерировался и выскакивала ошибка, либо конвертировался кривой и "терял" много данных из спецификации, например информацию о типах в POST-методе или описание отдельных полей. Было сделано предположение, что сайт работает не совсем корректно, и было решено настроить необходимые maven-плагины погрузиться в кротовую нору. На первый взгляд казалось, что дело буквально на 20 минут - зашел и сразу вышел скопировать конфиг плагинов и запустить команду "mvn package"

Нужно было добавить какую-то картинку, чтобы не было так скучно читать простыню текста
Нужно было добавить какую-то картинку, чтобы не было так скучно читать простыню текста

В кротовой норе

Итак, еще при первоначальном поиске решения был найден совет, что swagger-maven-plugin по генерации swagger-аннотации не очень то и нужен, хоть это и указывается практически везде. Достаточно написать тест, в котором поднимается Spring-контекст, и скачивается именно та спецификация, которая генерируется библиотекой springdoc, и этот файл сохраняется в каталог сборки для дальнейшей его обработки. Это исключает шанс того, что swagger-maven-plugin обрабатывает swagger-аннотации не совсем так, как springdoc.

@Slf4j
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PersistOpenApiTest {

	private static final String PERSIST_PATH = "target/api-doc.json";

	@LocalServerPort
	int localPort;

	@Value("${springdoc.api-docs.path}")
	String  apiDocPath;

	@Autowired
	ObjectMapper mapper;

	RestTemplate restTemplate = new RestTemplate();

	@Test
	public void persistOpenApiSpec() throws Exception {
		log.info("loading openApiSpec");
		String openApiSpec = restTemplate.getForObject("http://localhost:" + localPort +  apiDocPath, String.class);
		log.info("openApiSpec is {}", openApiSpec);
		Assertions.assertNotNull(openApiSpec);
		Files.writeString(Paths.get(PERSIST_PATH), prettyJson(openApiSpec));
	}

	private String prettyJson(String json) throws Exception {
		var mapSpec = mapper.readValue(json, Map.class);
		return mapper.writer().withDefaultPrettyPrinter()
				.writeValueAsString(mapSpec);
	}
}

Написать такой тест не составляет никакого труда.

Следующий этап - настроить плагин swagger2markup-maven-plugin, который на основе swagger-спецификации генерирует документацию в формате ASCII-doctor. И тут начинается самое веселье.

Оказывается, swagger2markup-maven-plugin требует 2 зависимости, которых нет в репозитории maven-central. Точнее данные о них есть, но указано что они находятся в репозитории Jcenter, который прекратил свое существование в 2021 году (если кто не знал), и по факту их нигде не сохранилось... В github-проекта swagger2markup было найдено issue об этом, но никакой обратной реакции от разработчиков не было.

Но нам повезло - один jar-файл удалось найти на каком-то китайском сайте, и даже при помощи переводчика найти, какой иероглиф означает "скачать", и сохранить эту библиотеку. Со второй библиотекой ситуация немного сложнее, пришлось скачать исходники, немного освоить работу с gradle, выпилить из проекта все ссылки на jcenter, и собрать библиотеку из исходников.

На всё это дело ушло часа 3-4, началось дейли, и ПМ заявил, что я занимаюсь ху... ерундой, легче заставить наших партнеров-специалистов по 1С работать напрямую со OpenAPI-спецификацией в формате JSON.

Таким образом можно сделать вывод, что на любимом всеми сайте baeldung.com, на stackoveflow и в других источниках рабочего варианта решения проблемы просто нет (либо мне его найти не удалось).

Шутка, было бы глупо, если бы на этом история завершилась. Данная проблема показалась мне уж больно интересной и нестандартной, да и я чувствовал, что уже был практически в шаге от её решения. Так что пришлось продолжить ее решать в свободное от работы время, в выходной день Вашего покорного слуги типичного быдлокодера.

Оставалось совсем немножко - настроить последний плагин asciidoctor-maven-plugin и запустить сборку. Так как мы модные и стильные, взяли не ту дремучую версию из статьи на baeldung, а последнюю доступную версию в maven-репозитории. Тому, что ничего не собралось, и плагин упал с ошибкой, я уже не удивился, немного поискал и нашёл рабочий пример в официальном репозитории asciidoctor-maven-plugin на github, оказалось, что по умолчанию плагин подтягивает версию ruby, с которой сам же и конфликтует, но на удивление в репозитории с примерами это учли и переопределили на рабочую версию ruby. Почему в самом плагине используется нерабочая версия ruby, остается тайной, покрытой мраком. Чем больше погружаешься в мир OpenSource-технологий, тем больше возникает вопросов...

По итогу, все собралось, запустилось, сгенерировался PDF-файл на основе спецификации OpenAPI 3, и мы пришли к тому, с чего начали - он был такой же "кривой", как и при генерации в онлайн-конвертере.

Оказалось, что плагин swagger2markup-maven-plugin не просто использует недоступные на данный момент зависимости, он попросту мертв - последний релиз был в 2018 году, а судя по issue в github'e, примерно до 2020 года велись работы по поддержке OpenAPI 3, но они не были завершены, и плагин поддерживает только Swagger 2. Ну а в 2020 его мейнтейнер Robert Winkler опубликовал открытое обращение к сообществу, что в одиночку не справляется и ищет кому передать эту ношу, и судя по всему, никого не нашёл.

Тупик. Уныние и безнадежность

Итак, я вернулся к тому, с чего начал. Какие же были варианты?

  • Сделать fork от swagger2markup-maven-plugin, и доделать поддержку OpenApi3, судя по всему какие-то заделы уже были реализованы, но не были доведены до конца. Вопрос только, на сколько часов трудозатрат этот вариант?

  • Ранее в ходе поисков в google были найдены проекты на github на основе JS, уже реализующие данную функциональность, возможно стоило попытаться реализовать maven-плагин, использующий запуск этих решений?

  • Продолжить google-research, углубиться на 3-4 страницу поисковой выдачи и надеяться что кто-то решил эту задачу...

Очевидно, начинать нужно с конца, так как первый вариант самый трудозатратный, второй - менее затратный, и третий с минимальными затратами. И мне повезло!

Рабочее решение

Оказывается в проекте openapi-generator существуют генераторы документации https://openapi-generator.tech/docs/generators/#documentation-generators

Ранее я уже пользовался openapi-generator-maven-plugin, но предполагал, что он способен на основе спецификации генерировать только клиент и модель классов для доступа к сервису. Однако, выяснилось, что прописав нужное имя генератора, вместо клиента он способен сгенерировать не клиент, а документацию - в формате html, html2 и asciidoctor. Интересно, что это была лишь гипотеза, нигде об этом напрямую не было написано, и ее пришлось проверять, чтобы удостовериться в работоспособности решения.

<plugin>
	<!--
		OpenApi provides generators for generation documentation
		https://openapi-generator.tech/docs/generators#documentation-generators
	-->
	<groupId>org.openapitools</groupId>
	<artifactId>openapi-generator-maven-plugin</artifactId>
	<version>7.1.0</version>
	<executions>
		<execution>
			<id>open-api-doc-html</id>
			<phase>prepare-package</phase>
			<goals>
				<goal>generate</goal>
			</goals>
			<configuration>
				<inputSpec>${project.basedir}/target/api-doc.json</inputSpec>
				<output>${project.basedir}/target/generated-doc/html</output>
				<!-- https://openapi-generator.tech/docs/generators/html/ -->
				<generatorName>html</generatorName>
			</configuration>
		</execution>
		<execution>
			<id>open-api-doc-html2</id>
			<phase>prepare-package</phase>
			<goals>
				<goal>generate</goal>
			</goals>
			<configuration>
				<inputSpec>${project.basedir}/target/api-doc.json</inputSpec>
				<output>${project.basedir}/target/generated-doc/html2</output>
				<!-- https://openapi-generator.tech/docs/generators/html2/ -->
				<generatorName>html2</generatorName>
			</configuration>
		</execution>
		<execution>
			<id>open-api-doc-asciidoc</id>
			<phase>prepare-package</phase>
			<goals>
				<goal>generate</goal>
			</goals>
			<configuration>
				<inputSpec>${project.basedir}/target/api-doc.json</inputSpec>
				<output>${project.basedir}/target/generated-doc/asciidoc</output>
				<!-- https://openapi-generator.tech/docs/generators/asciidoc/ -->
				<generatorName>asciidoc</generatorName>
			</configuration>
		</execution>
	</executions>
</plugin>

Плагин, сконфигурированный указанным выше способом - формирует документацию в 3 форматах - html, html2 и asciidoctor. Html и html2 представляют из себя уже 2 формата, удобных для восприятия человека и их можно отправлять непосредственно Вашим партнерам.

Однако, если нужен все-таки PDF, то необходимо настроить еще один плагин, который будет на основе asciidoctor генерировать PDF-файл.

<properties>
	<!--
		Default asciidoctor dependencies conflicted, use solution from
		https://github.com/asciidoctor/asciidoctor-maven-examples/blob/main/asciidoctor-pdf-example/pom.xml
	-->
	<asciidoctor.maven.plugin.version>2.2.4</asciidoctor.maven.plugin.version>
	<asciidoctorj.pdf.version>2.3.9</asciidoctorj.pdf.version>
	<asciidoctorj.version>2.5.10</asciidoctorj.version>
	<jruby.version>9.4.2.0</jruby.version>
</properties>

<!-- .... -->

<plugin>
	<groupId>org.asciidoctor</groupId>
	<artifactId>asciidoctor-maven-plugin</artifactId>
	<version>${asciidoctor.maven.plugin.version}</version>
	<dependencies>
		<dependency>
			<groupId>org.asciidoctor</groupId>
			<artifactId>asciidoctorj-pdf</artifactId>
			<version>${asciidoctorj.pdf.version}</version>
		</dependency>
		<!-- Comment this section to use the default jruby artifact provided by the plugin -->
		<dependency>
			<groupId>org.jruby</groupId>
			<artifactId>jruby</artifactId>
			<version>${jruby.version}</version>
		</dependency>
		<!-- Comment this section to use the default AsciidoctorJ artifact provided by the plugin -->
		<dependency>
			<groupId>org.asciidoctor</groupId>
			<artifactId>asciidoctorj</artifactId>
			<version>${asciidoctorj.version}</version>
		</dependency>
	</dependencies>
	<configuration>
		<sourceDirectory>${project.basedir}/target/generated-doc/asciidoc</sourceDirectory>
		<outputDirectory>${project.basedir}/target/generated-doc/pdf</outputDirectory>
	</configuration>
	<executions>
		<execution>
			<id>generate-pdf-doc</id>
			<phase>prepare-package</phase>
			<goals>
				<goal>process-asciidoc</goal>
			</goals>
			<configuration>
				<backend>pdf</backend>
				<attributes>
					<source-highlighter>rouge</source-highlighter>
					<icons>font</icons>
					<pagenums/>
					<toc/>
					<idprefix/>
					<idseparator>-</idseparator>
				</attributes>
			</configuration>
		</execution>
	</executions>
</plugin>

В итоге - у нас есть 5 вариантов документации

  • сама спецификация в формате OpenAPI 3 (сохраняется в unit-тесте)

  • html

  • html2 ("красивенький" html)

  • asciidoc

  • pdf

При желании можно поизучать настройки каждого генератора (ссылки приведены в комментариях в конфигурации плагина).

Исходный код демо-сервиса и настроенные плагины выложены в github

PS: у читателя может возникнуть вопрос - почему я не мог сразу описать рабочее решение, а расписал все свои мытарства? Отвечу просто - я потратил не один час рабочего времени, и еще намного больше личного времени, чтобы прийти к рабочему решению, так что думаю будет справедливым, если читатель почувствует хоть малый отголосок этой боли. А для англоязычного сообщества - я описал найденный мной способ в самом популярном вопросе на Stackoverflow на эту тему.

Комментарии (4)


  1. LeshaRB
    09.12.2023 21:21

    Проблема осложнялась тем, что наш партнер разрабатывал на 1С, и во всех современных спецификациях для него были слишком сложно освоиться, поэтому встала задача предоставить документацию в человекориентированном виде - DOC, PDF и прочее.

    У Swagger есть UI чем он не подошёл? За весь мой опыт, этого всегда хватало...
    Ок не подошёл UI, так вроде вы выгружаете в html, зачем pdf?


  1. martyncev
    09.12.2023 21:21

    О да, это всеобщая боль... В итоге лично я пришел к может и неправильному, но по сути верному опыту - если заказчик не понимает формат OpenAPI 3.0, не хочет его открыть в UI - то это не наши проблемы, а его. Пусть ищет тех, кто разберется. Для особо важных - даем пример клиента на Python.


    1. shmakovaa Автор
      09.12.2023 21:21

      Такой подход может привести, что заказчик больше не захочет сотрудничать с Вашей компаний, ну и как итог Вашей команде разработке придется искать новое место работы. Очень часто сталкивался с таким подходом в работе - скидывание проблем на других, он ни к чему хорошему не приводит. Если заказчик Ваш клиент, и платит Вам деньги - нужно демонстрировать клиентоориентированный подход и работать на результат, зачастую выходя за рамки должностной инструкции


  1. PrinceKorwin
    09.12.2023 21:21

    Зачетно, конечно. Но зачем эту логику имплементировать в каждом сервисе? Если придётся какой-то сервис переписать на другой язык (Go?), то это разобьёт вашу стройную схему.

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

    На своих проектах мы просто публикуем все публичные API отдельно, публикация этих API автоматическая после прохождения QA тестов и фиксации релиза.

    Отдел взаимодействия с клиентами берёт эти спецификации и их уже предоставляет Заказчикам. Если появляется какой-то заказчик, который готов заплатить за PDF/DOC/TeX - то это уже не головная боль разработчика.

    Это даёт нужную гибкость командам (не завязаны на конкретный язык/фреймворк, могут примерять как Contract First подход, так и Code First - тут всё на откуп Архитекторам), не завязаны на хотелки Заказчика (а теперь тоже самое, но с перламутровыми пуговками).

    Команда разработки не тратит своё дорогое время на это.