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", "[email protected]")).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函數)。該單元測試後生成文檔及目錄如下圖所示:
要使得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": "[email protected]"
}
},
"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的接口描述文件,這裏會生成三個文件,如下圖所示:
需要注意的是,生成的paths.adoc文件中描述每個接口的詳細信息,並以swagger.json文件中每個URL的summary屬性作爲每個接口的標題,而這個標題也與圖1中http請求響應片段文件的目錄相對應。如下圖所示:
現在所有片段文檔都已經生成,需要整合成一個文檔了。restdocs默認存放文檔模板的目錄爲src/main/asciidoc,如下圖所示:
這個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格式),其生成文件的默認目錄如下圖所示:
index.html最終呈現的效果如下圖所示:
6. 小結
swagger能夠生成使用了註解後的API接口信息,restdocs能夠根據單元測試生成對應接口的http請求響應消息示例,將這兩者結合就能夠生成既包含接口描述又包含請求響應消息示例的接口文檔。這裏restdocs生成的http請求響應消息的目錄使用獲取@ApiOperation的值來定義,以與swagger生成的文檔對應標題對應以實現消息片段的注入。