Spring Rest Docs WebTestClient自动生成接口文档Gradle版

文章同步在个人博客

以前接口文档都是用swagger2在线文档,最近升级为spring boot2 + webflux反应式编程后,swagger2不支持webflux,无法使用,因此文档生成改为官方的spring rest docs,实践过程中因为英文不咋地走了一些弯路,在这里记录一下吧。

1. 首先就是依赖和gradle插件

官方的gradle依赖,要注意的地方是plugins的位置。本文是markdown书写缺失一些asciidoc的显示

plugins { 
    id "org.asciidoctor.convert" version "1.5.3"
}

dependencies {
    asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor:2.0.2.RELEASE' 
    testCompile "org.springframework.restdocs:spring-restdocs-webtestclient:2.0.2.RELEASE" //如果为mvc换成mvc测试相关依赖
}

ext { 
    snippetsDir = file('build/generated-snippets')
}

test { 
    outputs.dir snippetsDir
}

asciidoctor { 
    inputs.dir snippetsDir 
    dependsOn test 
}
//spring boot打jar包的时候将生成的html5资源加入
bootJar {
    dependsOn asciidoctor
    from ("${asciidoctor.outputDir}/html5") {
        into 'static/public/docs'
    }
}
//Apply the Asciidoctor plugin.
//Add a dependency on spring-restdocs-asciidoctor in the asciidoctor configuration. This will automatically configure the snippets attribute for use in your .adoc files to point to build/generated-snippets. It will also allow you to use the operation block macro.
//Add a dependency on spring-restdocs-mockmvc in the testCompile configuration. If you want to use REST Assured rather than MockMvc, add a dependency on spring-restdocs-restassured instead.
//Configure a property to define the output location for generated snippets.
//Configure the test task to add the snippets directory as an output.
//Configure the asciidoctor task
//Configure the snippets directory as an input.
//Make the task depend on the test task so that the tests are run before the documentation is created.

2. 开始编写接口单元测试

代码如下,其中responseFields和requestFields的相关字段约束参考官网


import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.google.common.collect.Lists;
import lombok.extern.slf4j.Slf4j;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.SpringBootWebTestClientBuilderCustomizer;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.codec.CodecCustomizer;
import org.springframework.context.ApplicationContext;
import org.springframework.restdocs.JUnitRestDocumentation;
import org.springframework.restdocs.constraints.ConstraintDescriptions;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;

import java.util.Collection;
import java.util.Map;
import java.util.stream.Collectors;

import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.restdocs.snippet.Attributes.key;
import static org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation.document;
import static org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation.documentationConfiguration;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("dev")
@Slf4j
public class DocsGen {

    @Rule
    public final JUnitRestDocumentation restDocumentation = new JUnitRestDocumentation();

    @Autowired
    public ApplicationContext context;

    private WebTestClient webTestClient;


    @Before
    public void setUp() {
        this.webTestClient = WebTestClient.bindToApplicationContext(context)
                .configureClient().baseUrl("http://api.example.com/")
                .filter(documentationConfiguration(restDocumentation))
                .build();
    }

    @Test
    public void loginTest() {
        final ReactiveSecurityConfig.BUser bUser = new ReactiveSecurityConfig.BUser();
        bUser.setUsername("super");
        bUser.setPassword("12345");
        ConstraintDescriptions userConstraints = new ConstraintDescriptions(ReactiveSecurityConfig.BUser.class);
        webTestClient
                .post().uri("/api/login")
                .body(Mono.just(bUser), ReactiveSecurityConfig.BUser.class)
                .exchange().expectStatus().isOk()
                .expectBody()
                .consumeWith(document("login",  //生成adoc文档所在文件夹名称
                        requestFields(fieldWithPath("username")
                                        .description("用户名")
                                        .attributes(key("constraints").value(userConstraints.descriptionsForProperty("username"))),
                                fieldWithPath("password")
                                        .description("用户密码").attributes(key("constraints").value(userConstraints.descriptionsForProperty("password")))
                        ),
                        responseFields(fieldWithPath("id").description("id").attributes(key("constraints").value("")),
                                fieldWithPath("username").description("用户名").attributes(key("constraints").value("")),
                                fieldWithPath("agentId").description("企业号应用名称").attributes(key("constraints").value("")),
                                fieldWithPath("lastUpdatedAt").description("最近登录时间").attributes(key("constraints").value("")),
                                fieldWithPath("status").description("状态").attributes(key("constraints").value("")),
                                fieldWithPath("enabled").description("是否启用").attributes(key("constraints").value("")),
                                fieldWithPath("accountNonExpired").description("是否过期").attributes(key("constraints").value("")),
                                fieldWithPath("handler").description("null").attributes(key("constraints").value("")),
                                fieldWithPath("authorities[].authority").description("授权信息").attributes(key("constraints").value("")),
                                fieldWithPath("accountNonLocked").description("是否没有被锁").attributes(key("constraints").value("")),
                                fieldWithPath("credentialsNonExpired").description("认证是否过期").attributes(key("constraints").value("")))));
    }

}

跑一下单元测试试试,跑完后看一下build文件夹目录如下,框框里面就是生成的adoc文件,adoc文件是一种毕markdown更强档的书写文档格式,spring官网文档和很多大公司的文档都由其编写,adoc文档参考AsciiDoc 语法快速参考

image

request-fields.adoc和response-fields.adoc默认没有,需要在增加如图配置。具体内容基本相同
image

request-fields.adoc和response-fields.adoc具体内容

|===
|路径|类型|描述|约束
{{#fields}}
|{{#tableCellContent}}`{{path}}`{{/tableCellContent}}
|{{#tableCellContent}}`{{type}}`{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}
|{{#tableCellContent}}{{constraints}}{{/tableCellContent}}
{{/fields}}
|===

打开http-request.adoc、http-response.adoc看看,发现json都是未格式化的,很不美观。没关系改下配置让其美化一下吧,美化的关键是在ObjectMapper的打印配置,修改如下:

  • 先全局序列化消息配置

@Configuration
@EnableScheduling
public class AppConfig implements ApplicationContextAware {


    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        AppContextUtils.setCtx(applicationContext);
    }


    /**
     * XML报文序列化器
     * JsonInclude.Include.NON_NULL 序列化是忽略null字段
     * SerializationFeature.FAIL_ON_EMPTY_BEANS, false懒加载异常消除
     *
     * @return xmlMapper
     */
    @Bean
    public XmlMapper xmlMapper() {
        final XmlMapper xmlMapper = new XmlMapper();
        xmlMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        xmlMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
        xmlMapper.enable(SerializationFeature.INDENT_OUTPUT);
        return xmlMapper;
    }

    /**
     * Json报文序列化器
     * JsonInclude.Include.NON_NULL 序列化是忽略null字段
     * SerializationFeature.FAIL_ON_EMPTY_BEANS, false懒加载异常消除
     *
     * @return xmlMapper
     */
    @Bean
    public ObjectMapper objectMapper() {
        return Jackson2ObjectMapperBuilder.json()
                .serializationInclusion(JsonInclude.Include.NON_NULL)
                .build()
                .configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
                .enable(SerializationFeature.INDENT_OUTPUT);//美化json字符串打印输出
    }

    /**
     * 默认就有多种httpMessage消息序列化器,这里自定义json和xml转换器
     *
     * @return CodecCustomizer 自定义转换器
     */
    @Bean
    @ConditionalOnBean(ObjectMapper.class)
    public CodecCustomizer jacksonCodecCustomizer(@Qualifier("jsonEncoder") Jackson2JsonEncoder jackson2JsonEncoder,
                                                  Jackson2JsonDecoder jackson2JsonDecoder,
                                                  CustomJaxb2XmlDecoder jaxb2XmlDecoder,
                                                  @Qualifier("xmlEncoder") Jackson2JsonEncoder xmlEncoder) {

        return (configurer) -> {
            CodecConfigurer.DefaultCodecs defaults = configurer.defaultCodecs();
            /*
                json反序列化器注册
             */
            defaults.jackson2JsonDecoder(
                    jackson2JsonDecoder);
            /*
                json序列化器注册
             */
            defaults.jackson2JsonEncoder(
                    jackson2JsonEncoder);
            final CodecConfigurer.CustomCodecs customCodecs = configurer.customCodecs();
            /*
                xml序列化反序列化器注册
             */
            customCodecs.decoder(jaxb2XmlDecoder);
            customCodecs.encoder(xmlEncoder);
        };
    }

    @Bean("jsonEncoder")
    public Jackson2JsonEncoder jackson2JsonEncoder(ObjectMapper objectMapper) {
        return new Jackson2JsonEncoder(objectMapper, this.jsonMimeTypes());
    }

    @Bean
    public Jackson2JsonDecoder jackson2JsonDecoder(ObjectMapper objectMapper) {
        return new Jackson2JsonDecoder(objectMapper, this.jsonMimeTypes());
    }

    @Bean
    public CustomJaxb2XmlDecoder jaxb2XmlDecoder() {
        return new CustomJaxb2XmlDecoder(this.xmlMimeTypes());
    }

    @Bean("xmlEncoder")
    public Jackson2JsonEncoder xmlEncoder(XmlMapper xmlMapper) {
        return new Jackson2JsonEncoder(xmlMapper, this.xmlMimeTypes());
    }

    private MimeType[] xmlMimeTypes() {
        return new MimeType[]{MimeTypeUtils.APPLICATION_XML, MimeTypeUtils.TEXT_HTML, MimeTypeUtils.TEXT_XML};
    }

    private MimeType[] jsonMimeTypes() {
        return new MimeType[]{
                new MimeType("application", "json", StandardCharsets.UTF_8),
                new MimeType("application", "*+json", StandardCharsets.UTF_8),
                MimeTypeUtils.TEXT_PLAIN
        };
    }

}
  • 在将配置应用到WebTestClient,只需修改DocsGen的setUp()方法:
    //注入上届的配置
    @Autowired
    private CodecCustomizer codecCustomizer;

    private WebTestClient webTestClient;


    @Before
    public void setUp() {
        final WebTestClient.Builder builder = WebTestClient.bindToApplicationContext(context)
                .configureClient();

        final SpringBootWebTestClientBuilderCustomizer builderCustomizer =
                new SpringBootWebTestClientBuilderCustomizer(Lists.newArrayList(codecCustomizer));
        //将自定义的序列化配置应用到webTestCliend
        builderCustomizer.customize(builder);

        this.webTestClient = builder.baseUrl("http://laprairie-enterprise.d.d1miao.com/")
                .filter(documentationConfiguration(restDocumentation))
                .build();
    }

重跑一边单元测试,查看上面的文件request-body.adoc,美化的json字符串好看多了。

[source,options="nowrap"]
----
{
  "username" : "admin",
  "password" : "12345"
}
----

3. 编写入口index.adoc和生成hmtl文件

上面生成了很多.adoc的文件,现在根据asciidoc德与法编写入口index文件。gradle项目在src目录下新建docs/asciidoc目录(maven项目在其他目录),然后新建两个文件如下图,后面跑完脚本会生成对应的html页面


image

login.adoc内容如下,主要是将build/generated-snippets下生成的文件导入进来

== *Backend user login:*

include::{snippets}/login/curl-request.adoc[]

=== Request using HTTPie:

include::{snippets}/login/httpie-request.adoc[]

=== HTTP request:

include::{snippets}/login/http-request.adoc[]

=== Request body:

include::{snippets}/login/request-body.adoc[]

==== Request fields:

include::{snippets}/login/request-fields.adoc[]

=== HTTP response:

include::{snippets}/login/http-response.adoc[]

=== Response body:

include::{snippets}/login/response-body.adoc[]

==== Response fields:

include::{snippets}/login/response-fields.adoc[]

然后编写index.adoc文件,导入上面的login.adoc,如果还有其他adoc文件也可以都导入,按顺序编写导入

= XXX项目api文档
Jone Wang;
:toc: left ➊
:toc-title: 章节
:doctype: book
:icons: font
:source-highlighter: highlightjs

include::login.adoc[]

➊ 目录在左侧

现在执行gradle命令编译

./gradlew -Dorg.gradle.daemon=false -Dtest.enabled=true clean test asciidoctor bootJar

pc平台去掉最前面的'./'' 因为我test.enabled默认配置未disable所以加了加了参数-Dtest.enabled=true

image

编译完毕后在build/asciiadoc/html5找到生成的html文件
image

用浏览器打开index.html看看,非常漂亮的api文档。
image

最后提示spring boot默认指定static为静态资源目录,如有修改需要重新配置gradle的bootJar,将into
改成自定义的目录

bootJar {
    dependsOn asciidoctor
    from ("${asciidoctor.outputDir}/html5") {
        into 'static/public/docs'
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,542评论 6 504
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,822评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,912评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,449评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,500评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,370评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,193评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,074评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,505评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,722评论 3 335
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,841评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,569评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,168评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,783评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,918评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,962评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,781评论 2 354