Springboot中自动生成REST API文档的实践
1. 问题的提出
在web项目中,常需要提供REST API作为接口文档,供测试或二次开发使用。手工编写接口文档工作比较繁重,现在springboot项目中可以使用spring restdocs和swagger两类工具结合来生成自定义式样的REST API文档。
swagger是一个老牌restapi测试工具,可以使用它来生成测试rest api接口的页面,相信大多数同学对它的使用都不陌生。spring restdocs可以从单元测试的测试用例中生成对应的http请求与响应片段,并根据设置的模板文档 (.adoc格式)生成最终发布文档。
使用restdocs和swagger结合的目的是让swagger根据用户定义(标注)的API生成API摘要,然后再根据restdocs生成的http请求与响应片段填入到这些API摘要文档中组合成最终文档。
下面我们看一下如何具体操作。
2. pom文件配置
2.1 加入依赖包
swagger的依赖包:
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.5.0</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.5.0</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-staticdocs</artifactId>
<version>2.6.1</version>
</dependency>
restdocs的依赖包:
<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-mockmvc</artifactId>
<version>${spring-restdocs.version}</version>
<scope>test</scope>
</dependency>
2.2 加入restdocs插件
注意在插件中定义了一个属性作为环境变量:generated是swagger生成API摘要文件的文件目录。
<plugin>
<groupId>org.asciidoctor</groupId>
<artifactId>asciidoctor-maven-plugin</artifactId>
<version>${plugin-asciidoctor.version}</version>
<executions>
<execution>
<id>generate-docs</id>
<phase>prepare-package</phase>
<goals>
<goal>process-asciidoc</goal>
</goals>
<configuration>
<backend>html</backend>
<doctype>book</doctype>
<attributes>
<generated>${project.build.directory}/swagger</generated>
</attributes>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-asciidoctor</artifactId>
<version>${spring-restdocs.version}</version>
</dependency>
</dependencies>
</plugin>
3.使用swagger定义Rest API接口
本文示例中使用的swagger配置文件定义如下:
@Configuration
@EnableSwagger2
public class SwaggerConfig {
@Bean
public Docket swaggerSpringMvcPlugin() {
return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select()
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class)).build();
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder().title("设备分组服务测试").description("单元测试restdocdemo")
.contact(new Contact("智能运维产品部", "http://com.kedacom.com", "ioms@kedacom.com")).version("1.0").build();
}
}
本文示例中使用的Controller定义如下:
@Controller
@RequestMapping("/groupdevice/group")
@Api(value = "设备分组的REST接口")
public class GroupController {
@Autowired
private IGroupService groupService;
@Data
@AllArgsConstructor
public static class ResultBody {
//public ResultBody() {};
private int errCode;
private Object resultObj;
}
@RequestMapping(value = "/addGroup", method = RequestMethod.POST)
@ResponseBody
@ApiOperation(value="创建分组",notes = "创建一个新的分组")
@ApiImplicitParam(name = "groupBody", value = "分组信息", required = true, paramType = "body",dataType = "Group")
public ResultBody addGroup(@RequestBody Group groupBody) {
if (groupService.save(groupBody)) {
return new ResultBody(0, "add group ok.");
} else {
return new ResultBody(-1, "add group fail.");
}
}
@RequestMapping(value = "/findGroupById", method = RequestMethod.GET)
@ResponseBody
@ApiOperation(value="查找分组",notes = "按分组ID查找分组")
@ApiImplicitParam(name = "id", value = "分组ID", required = true, paramType = "query",dataType = "string")
public ResultBody findGroup(@RequestParam(required = false) String id) {
if (null != id) {
Group group = groupService.getById(id);
return new ResultBody(0, group);
} else {
return new ResultBody(-2, "parameter is invalid.");
}
}
@RequestMapping(value = "/deleteGroupById", method = RequestMethod.DELETE)
@ResponseBody
@ApiOperation(value="删除分组",notes = "按分组ID删除分组")
@ApiImplicitParam(name = "id", value = "分组ID", required = true, paramType = "query", dataType = "string")
public ResultBody delGroup(@RequestParam(required = false) String id) {
if (null != id) {
if (groupService.removeById(id)) {
return new ResultBody(0, "delete group ok.");
} else {
return new ResultBody(-1, "delete group fail.");
}
} else {
return new ResultBody(-2, "parameter is invalid.");
}
}
}
该Controller中定义了3个API接口。
4. 编写单元测试
根据上节定义的接口,可使用restdocs提供的MockMvc进行单元测试并生成对应的http请求与响应片段。单元测试代码如下:
package com.kedacom.ioms.groupdevice.controller;
import static org.junit.Assert.*;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.lang.reflect.Method;
import org.junit.*;
import org.junit.runner.RunWith;
import org.junit.FixMethodOrder;
import org.junit.runners.MethodSorters;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.web.bind.annotation.RequestMethod;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.kedacom.ioms.groupdevice.entity.Group;
import com.kedacom.ioms.groupdevice.service.IGroupService;
import io.github.robwin.markup.builder.MarkupLanguage;
import io.github.robwin.swagger2markup.GroupBy;
import io.github.robwin.swagger2markup.Swagger2MarkupConverter;
import io.swagger.annotations.ApiOperation;
import springfox.documentation.staticdocs.SwaggerResultHandler;
/**
* @author zhang.kai
*
*/
@RunWith(SpringRunner.class)
@SpringBootTest
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
@AutoConfigureMockMvc
@AutoConfigureRestDocs(outputDir = "target/snippets")
public class GroupControllerTest {
// swagger生成的adoc文件存放目录
private static final String swaggerOutputDir = "target/swagger";
// http请求回应生成的snippets文件存放目录
private static final String snippetsOutputDir = "target/snippets";
@Autowired
private IGroupService groupService;
@Autowired
private MockMvc mvc;
/**
* 断言rest结果是否正确,根据response结果中的errorcode是否为0来判断
*
* @param response
* @throws Exception
*/
@SuppressWarnings("unused")
private void assertResultOk(MockHttpServletResponse response) throws Exception {
assertNotNull(response);
String retstr = response.getContentAsString();
GroupController.ResultBody rb = JSON.parseObject(retstr, GroupController.ResultBody.class);
assertNotNull(rb);
assertEquals(0, rb.getErrCode());
}
/**
* 返回controller中api方法的@ApiOperation注解中的value值
*
* @param classname
* @param methodname
* @return
* @throws Exception
*/
private String getApiOperValue(String classname, String methodname, Class<?>... parameterTypes) throws Exception {
Class classApi = Class.forName(classname);
Method method = classApi.getMethod(methodname, parameterTypes);
ApiOperation apiOper = method.getAnnotation(ApiOperation.class);
return apiOper.value();
}
/**
* 执行rest操作
*
* @param requestUrl
* @param method
* @param docOutDir: 生成请求消息体存放的文件目录,本示例中使用ApiOperation的value作为目录名
* @param requestBody:请求的消息体
* @throws Exception
*/
private MockHttpServletResponse performRestRequest(String requestUrl, RequestMethod method, String docOutDir,
Object... requestBody) throws Exception {
MvcResult mvcResult;
MockHttpServletResponse response;
switch (method) {
case GET:
mvcResult = mvc.perform(MockMvcRequestBuilders.get(requestUrl).accept(MediaType.APPLICATION_JSON_UTF8))
.andDo(print())
.andDo(document(docOutDir, preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint())))
.andReturn();
break;
case POST:
mvcResult = mvc
.perform(MockMvcRequestBuilders.post(requestUrl).contentType(MediaType.APPLICATION_JSON_UTF8)
.content(JSON.toJSONString(requestBody[0])).accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andDo(document(docOutDir, preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint())))
.andReturn();
break;
case PUT:
mvcResult = mvc
.perform(MockMvcRequestBuilders.put(requestUrl).contentType(MediaType.APPLICATION_JSON_UTF8)
.content(JSON.toJSONString(requestBody[0])).accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andDo(document(docOutDir, preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint())))
.andReturn();
break;
case DELETE:
mvcResult = mvc.perform(MockMvcRequestBuilders.delete(requestUrl).accept(MediaType.APPLICATION_JSON_UTF8))
.andDo(print())
.andDo(document(docOutDir, preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint())))
.andReturn();
break;
default:
return null;
}
response = mvcResult.getResponse();
return response;
}
@Test
public void test01AddGroup() throws Exception {
Group groupIns = new Group();
groupIns.setVid(1l);
groupIns.setSyncRoundNum(1);
groupIns.setTreeId("00001");
groupIns.setIdParent("parentid1");
groupIns.setIdParentOrginal("idParentOrginal1");
groupIns.setId("id1");
groupIns.setIdOrginal("idorginal1");
groupIns.setLeft(1);
groupIns.setRight(100);
groupIns.setLevel(4);
groupIns.setRank(1);
groupIns.setGbId("4200000123400000000");
groupIns.setGbIdOrginal("4200000123400000000");
groupIns.setName("group1");
String requestUrl = "/groupdevice/group/" + "addGroup";
String docOutDir = getApiOperValue("com.kedacom.ioms.groupdevice.controller.GroupController", "addGroup",
Group.class);
MockHttpServletResponse response = performRestRequest(requestUrl, RequestMethod.POST, docOutDir, groupIns);
assertResultOk(response);
}
@Test
public void test02FindGroup() throws Exception {
// 取最大vid记录查询之
Group group1 = groupService.getOne(new QueryWrapper<Group>().select("vid").orderByDesc("vid").last("limit 1"));
assertNotNull(group1);
String requestUrl = "/groupdevice/group/" + "findGroupById" + "?id=" + group1.getVid();
String docOutDir = getApiOperValue("com.kedacom.ioms.groupdevice.controller.GroupController", "findGroup",
String.class);
MockHttpServletResponse response = performRestRequest(requestUrl, RequestMethod.GET, docOutDir);
assertResultOk(response);
}
@Test
public void test03DelGroup() throws Exception {
// 取最大vid记录删除之
Group group1 = groupService.getOne(new QueryWrapper<Group>().select("vid").orderByDesc("vid").last("limit 1"));
assertNotNull(group1);
String requestUrl = "/groupdevice/group/" + "deleteGroupById" + "?id=" + group1.getVid();
String docOutDir = getApiOperValue("com.kedacom.ioms.groupdevice.controller.GroupController", "delGroup",
String.class);
MockHttpServletResponse response = performRestRequest(requestUrl, RequestMethod.DELETE, docOutDir);
assertResultOk(response);
}
/**
* 这是一个特殊的测试用例,目的仅为生成一个swagger.json的文件
* @throws Exception
*/
@Test
public void genSwaggerFile() throws Exception {
// 得到swagger.json,写入outputDir目录中
mvc.perform(MockMvcRequestBuilders.get("/v2/api-docs").accept(MediaType.APPLICATION_JSON))
.andDo(SwaggerResultHandler.outputDirectory(swaggerOutputDir).build()).andExpect(status().isOk())
.andReturn();
}
/**
* 所有的测试用例执行完之后执行该函数,根据生成的swagger.json和snippets生成对应的adoc文档
* @throws Exception
*/
@AfterClass
public static void outputSwaggerDoc() throws Exception {
// 读取上一步生成的swagger.json转成asciiDoc,写入到outputDir
// 这个outputDir必须和插件里面<generated></generated>标签配置一致
Swagger2MarkupConverter.from(swaggerOutputDir + "/swagger.json").withPathsGroupedBy(GroupBy.TAGS)// 按tag排序
.withMarkupLanguage(MarkupLanguage.ASCIIDOC)// 格式
.withExamples(snippetsOutputDir).build().intoFolder(swaggerOutputDir);// 输出
}
}
在这个单元测试中,MockMvc执行API调用并将生成的http请求响应片段输出到snippetsOutputDir目录中(performRestRequest函数),snippetsOutputDir目录中对于每个API片段的目录使用的是swagger注解中@ApiOperation的value值(getApiOperValue函数)。该单元测试后生成文档及目录如下图所示:
图1.png
要使得swagger的接口摘要中使用这些http请求响应片段,需要首先swagger根据注解生成API接口摘要文档。函数genSwaggerFile根据swagger注解生成swagger.json文件,该文件内容如下:
{
"swagger": "2.0",
"info": {
"description": "单元测试restdocdemo",
"version": "1.0",
"title": "设备分组服务测试",
"contact": {
"name": "智能运维产品部",
"url": "http://com.kedacom.com",
"email": "ioms@kedacom.com"
}
},
"host": "localhost:8080",
"basePath": "/",
"tags": [
{
"name": "group-controller",
"description": "Group Controller"
}
],
"paths": {
"/groupdevice/group/addGroup": {
"post": {
"tags": [
"group-controller"
],
"summary": "创建分组",
"description": "创建一个新的分组",
"operationId": "addGroupUsingPOST",
"consumes": [
"application/json"
],
"produces": [
"*/*"
],
"parameters": [
{
"in": "body",
"name": "groupBody",
"description": "分组信息",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/ResultBody"
}
},
"201": {
"description": "Created"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
}
}
},
"/groupdevice/group/deleteGroupById": {
"delete": {
"tags": [
"group-controller"
],
"summary": "删除分组",
"description": "按分组ID删除分组",
"operationId": "delGroupUsingDELETE",
"consumes": [
"application/json"
],
"produces": [
"*/*"
],
"parameters": [
{
"name": "id",
"in": "query",
"description": "分组ID",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/ResultBody"
}
},
"401": {
"description": "Unauthorized"
},
"204": {
"description": "No Content"
},
"403": {
"description": "Forbidden"
}
}
}
},
"/groupdevice/group/findGroupById": {
"get": {
"tags": [
"group-controller"
],
"summary": "查找分组",
"description": "按分组ID查找分组",
"operationId": "findGroupUsingGET",
"consumes": [
"application/json"
],
"produces": [
"*/*"
],
"parameters": [
{
"name": "id",
"in": "query",
"description": "分组ID",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/ResultBody"
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
}
}
}
},
"definitions": {
"Group": {
"type": "object",
"properties": {
"gbId": {
"type": "string"
},
"gbIdOrginal": {
"type": "string"
},
"id": {
"type": "string"
},
"idOrginal": {
"type": "string"
},
"idParent": {
"type": "string"
},
"idParentOrginal": {
"type": "string"
},
"left": {
"type": "integer",
"format": "int32"
},
"level": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string"
},
"rank": {
"type": "integer",
"format": "int32"
},
"right": {
"type": "integer",
"format": "int32"
},
"syncRoundNum": {
"type": "integer",
"format": "int32"
},
"treeId": {
"type": "string"
},
"vid": {
"type": "integer",
"format": "int64"
}
}
},
"ResultBody": {
"type": "object",
"properties": {
"errCode": {
"type": "integer",
"format": "int32"
},
"resultObj": {
"type": "object"
}
}
}
}
}
outputSwaggerDoc函数根据上面的swagger.json文件,并结合图1目录中的http请求响应片段文件生成rest api的接口描述文件,这里会生成三个文件,如下图所示:
图2.png
需要注意的是,生成的paths.adoc文件中描述每个接口的详细信息,并以swagger.json文件中每个URL的summary属性作为每个接口的标题,而这个标题也与图1中http请求响应片段文件的目录相对应。如下图所示:
图3.png现在所有片段文档都已经生成,需要整合成一个文档了。restdocs默认存放文档模板的目录为src/main/asciidoc,如下图所示:
图4.png
这个index.adoc是我们预置的,长这个样子。请注意这里引用了“{generated}”的环境变量,是在2.2节中设置的swagger文件生成的接口文档片段。它把swagger目录下生成的三个文件导入到一个文件中。
include::{generated}/overview.adoc[]
include::{generated}/definitions.adoc[]
include::{generated}/paths.adoc[]
5. maven构建生成最终文档
使用“mvn package”命令可构建工程并打包,同时也生成最终文档(为pom文件中定义的html格式),其生成文件的默认目录如下图所示:
图5.png
index.html最终呈现的效果如下图所示:
图6.png
6. 小结
swagger能够生成使用了注解后的API接口信息,restdocs能够根据单元测试生成对应接口的http请求响应消息示例,将这两者结合就能够生成既包含接口描述又包含请求响应消息示例的接口文档。这里restdocs生成的http请求响应消息的目录使用获取@ApiOperation的值来定义,以与swagger生成的文档对应标题对应以实现消息片段的注入。