From aed55a97090826f396eb080d1cf174be518ca4d7 Mon Sep 17 00:00:00 2001 From: chenjianxing Date: Wed, 30 Dec 2020 22:00:00 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=AF=E6=8C=81swagger=203.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/pom.xml | 12 +- .../api/parse/ApiImportAbstractParser.java | 37 +- .../metersphere/api/parse/Swagger2Parser.java | 32 +- .../metersphere/api/parse/Swagger3Parser.java | 365 ++++++++++++++++++ .../metersphere/commons/utils/XMLUtils.java | 47 +++ .../controller/TestController.java | 10 + frontend/src/i18n/en-US.js | 2 +- frontend/src/i18n/zh-CN.js | 2 +- frontend/src/i18n/zh-TW.js | 2 +- 9 files changed, 465 insertions(+), 44 deletions(-) create mode 100644 backend/src/main/java/io/metersphere/api/parse/Swagger3Parser.java create mode 100644 backend/src/main/java/io/metersphere/commons/utils/XMLUtils.java diff --git a/backend/pom.xml b/backend/pom.xml index 7c1e20dd8e..0e4ec205fe 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -270,12 +270,12 @@ 1.0.51 - - - - - - + + + io.swagger.parser.v3 + swagger-parser + 2.0.18 + diff --git a/backend/src/main/java/io/metersphere/api/parse/ApiImportAbstractParser.java b/backend/src/main/java/io/metersphere/api/parse/ApiImportAbstractParser.java index 4b80284658..c3b33ae0c8 100644 --- a/backend/src/main/java/io/metersphere/api/parse/ApiImportAbstractParser.java +++ b/backend/src/main/java/io/metersphere/api/parse/ApiImportAbstractParser.java @@ -70,6 +70,30 @@ public abstract class ApiImportAbstractParser implements ApiImportParser { } } + protected String getBodyType(String contentType) { + String bodyType = ""; + switch (contentType) { + case "application/x-www-form-urlencoded": + bodyType = Body.WWW_FROM; + break; + case "multipart/form-data": + bodyType = Body.FORM_DATA; + break; + case "application/json": + bodyType = Body.JSON; + break; + case "application/xml": + bodyType = Body.XML; + break; + case "application/octet-stream": + bodyType = Body.BINARY; + break; + default: + bodyType = Body.RAW; + } + return bodyType; + } + protected ApiDefinitionResult buildApiDefinition(String id, String name, String path, String method) { ApiDefinitionResult apiDefinition = new ApiDefinitionResult(); apiDefinition.setName(name); @@ -150,17 +174,4 @@ public abstract class ApiImportAbstractParser implements ApiImportParser { headers.add(new KeyValue(key, value, description)); } } -// protected void addHeader(HttpRequest request, String key, String value) { -// List headers = Optional.ofNullable(request.getHeaders()).orElse(new ArrayList<>()); -// boolean hasContentType = false; -// for (KeyValue header : headers) { -// if (StringUtils.equalsIgnoreCase(header.getName(), key)) { -// hasContentType = true; -// } -// } -// if (!hasContentType) { -// headers.save(new KeyValue(key, value)); -// } -// request.setHeaders(headers); -// } } diff --git a/backend/src/main/java/io/metersphere/api/parse/Swagger2Parser.java b/backend/src/main/java/io/metersphere/api/parse/Swagger2Parser.java index 4ce1a57eb0..2a04cd5898 100644 --- a/backend/src/main/java/io/metersphere/api/parse/Swagger2Parser.java +++ b/backend/src/main/java/io/metersphere/api/parse/Swagger2Parser.java @@ -32,11 +32,19 @@ public class Swagger2Parser extends ApiImportAbstractParser { @Override public ApiDefinitionImport parse(InputStream source, ApiTestImportRequest request) { Swagger swagger; + String sourceStr = ""; if (StringUtils.isNotBlank(request.getSwaggerUrl())) { swagger = new SwaggerParser().read(request.getSwaggerUrl()); } else { - swagger = new SwaggerParser().readWithInfo(getApiTestStr(source)).getSwagger(); + sourceStr = getApiTestStr(source); + swagger = new SwaggerParser().readWithInfo(sourceStr).getSwagger(); } + + if (swagger == null || swagger.getSwagger() == null) { + Swagger3Parser swagger3Parser = new Swagger3Parser(); + return swagger3Parser.parse(sourceStr, request); + } + ApiDefinitionImport definitionImport = new ApiDefinitionImport(); this.projectId = request.getProjectId(); definitionImport.setData(parseRequests(swagger, request.isSaved())); @@ -144,27 +152,7 @@ public class Swagger2Parser extends ApiImportAbstractParser { return Body.RAW; } String contentType = operation.getConsumes().get(0); - String bodyType = ""; - switch (contentType) { - case "application/x-www-form-urlencoded": - bodyType = Body.WWW_FROM; - break; - case "multipart/form-data": - bodyType = Body.FORM_DATA; - break; - case "application/json": - bodyType = Body.JSON; - break; - case "application/xml": - bodyType = Body.XML; - break; - case "": - bodyType = Body.BINARY; - break; - default: - bodyType = Body.RAW; - } - return bodyType; + return getBodyType(contentType); } private void parsePathParameters(Parameter parameter, List rests) { diff --git a/backend/src/main/java/io/metersphere/api/parse/Swagger3Parser.java b/backend/src/main/java/io/metersphere/api/parse/Swagger3Parser.java new file mode 100644 index 0000000000..135c6f7a28 --- /dev/null +++ b/backend/src/main/java/io/metersphere/api/parse/Swagger3Parser.java @@ -0,0 +1,365 @@ +package io.metersphere.api.parse; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import io.metersphere.api.dto.ApiTestImportRequest; +import io.metersphere.api.dto.definition.ApiDefinitionResult; +import io.metersphere.api.dto.definition.parse.ApiDefinitionImport; +import io.metersphere.api.dto.definition.request.sampler.MsHTTPSamplerProxy; +import io.metersphere.api.dto.definition.response.HttpResponse; +import io.metersphere.api.dto.scenario.Body; +import io.metersphere.api.dto.scenario.KeyValue; +import io.metersphere.api.dto.scenario.request.RequestType; +import io.metersphere.api.service.ApiModuleService; +import io.metersphere.base.domain.ApiModule; +import io.metersphere.commons.exception.MSException; +import io.metersphere.commons.utils.CommonBeanFactory; +import io.metersphere.commons.utils.LogUtil; +import io.metersphere.commons.utils.XMLUtils; +import io.swagger.models.parameters.FormParameter; +import io.swagger.parser.OpenAPIParser; +import io.swagger.v3.oas.models.*; +import io.swagger.v3.oas.models.headers.Header; +import io.swagger.v3.oas.models.media.*; +import io.swagger.v3.oas.models.parameters.*; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import io.swagger.v3.parser.core.models.SwaggerParseResult; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpMethod; + +import java.io.InputStream; +import java.util.*; + + +public class Swagger3Parser extends ApiImportAbstractParser { + + private Components components; + + @Override + public ApiDefinitionImport parse(InputStream source, ApiTestImportRequest request) { + String sourceStr = ""; + if (StringUtils.isBlank(request.getSwaggerUrl())) { + sourceStr = getApiTestStr(source); + } + return parse(sourceStr, request); + } + + public ApiDefinitionImport parse(String sourceStr, ApiTestImportRequest request) { + SwaggerParseResult result; + if (StringUtils.isNotBlank(request.getSwaggerUrl())) { + result = new OpenAPIParser().readLocation("https://petstore3.swagger.io/api/v3/openapi.json", null, null); + } else { + result = new OpenAPIParser().readContents(sourceStr, null, null); + } + + if (result == null) { + MSException.throwException("解析失败,请确认选择的是 swagger 格式!"); + } + + OpenAPI openAPI = result.getOpenAPI(); + + if (result.getMessages() != null) { + result.getMessages().forEach(msg -> LogUtil.error(msg)); // validation errors and warnings + } + + ApiDefinitionImport definitionImport = new ApiDefinitionImport(); + this.projectId = request.getProjectId(); + definitionImport.setData(parseRequests(openAPI, request.isSaved())); + return definitionImport; + } + + private List parseRequests(OpenAPI openAPI, boolean isSaved) { + Paths paths = openAPI.getPaths(); + + Set pathNames = paths.keySet(); + + this.components = openAPI.getComponents(); + + List results = new ArrayList<>(); + + for (String pathName : pathNames) { + PathItem pathItem = paths.get(pathName); + + Map operationsMap = new HashMap<>(); + operationsMap.put(HttpMethod.GET.name(), pathItem.getGet()); + operationsMap.put(HttpMethod.POST.name(), pathItem.getPost()); + operationsMap.put(HttpMethod.DELETE.name(), pathItem.getDelete()); + operationsMap.put(HttpMethod.PUT.name(), pathItem.getPut()); + operationsMap.put(HttpMethod.PATCH.name(), pathItem.getPatch()); + operationsMap.put(HttpMethod.HEAD.name(), pathItem.getHead()); + operationsMap.put(HttpMethod.OPTIONS.name(), pathItem.getOptions()); + operationsMap.put(HttpMethod.TRACE.name(), pathItem.getTrace()); + + for (String method : operationsMap.keySet()) { + Operation operation = operationsMap.get(method); + if (operation != null) { + MsHTTPSamplerProxy request = buildRequest(operation, pathName, method); + ApiDefinitionResult apiDefinition = buildApiDefinition(request.getId(), operation, pathName, method); + parseParameters(operation, request); + parseRequestBody(operation.getRequestBody(), request.getBody()); + apiDefinition.setRequest(JSON.toJSONString(request)); + apiDefinition.setResponse(JSON.toJSONString(parseResponse(operation.getResponses()))); + buildModule(apiDefinition, operation, isSaved); + results.add(apiDefinition); + } + } + } + + return results; + } + + private void buildModule(ApiDefinitionResult apiDefinition, Operation operation, boolean isSaved) { + List tags = operation.getTags(); + if (tags != null) { + tags.forEach(tag -> { + apiModuleService = CommonBeanFactory.getBean(ApiModuleService.class); + ApiModule module = apiModuleService.getNewModule(tag, this.projectId, 1); + if (isSaved) { + createModule(module); + } + apiDefinition.setModuleId(module.getId()); + }); + } + } + + private ApiDefinitionResult buildApiDefinition(String id, Operation operation, String path, String method) { + String name = ""; + if (StringUtils.isNotBlank(operation.getSummary())) { + name = operation.getSummary(); + } else { + name = operation.getOperationId(); + } + return buildApiDefinition(id, name, path, method); + } + + private MsHTTPSamplerProxy buildRequest(Operation operation, String path, String method) { + String name = ""; + if (StringUtils.isNotBlank(operation.getSummary())) { + name = operation.getSummary(); + } else { + name = operation.getOperationId(); + } + return buildRequest(name, path, method); + } + + private void parseParameters(Operation operation, MsHTTPSamplerProxy request) { + + List parameters = operation.getParameters(); + + if (CollectionUtils.isEmpty(parameters)) { + return; + } + + // todo 路径变量 {xxx} 是否要转换 + parameters.forEach(parameter -> { + if (parameter instanceof QueryParameter) { + parseQueryParameters(parameter, request.getArguments()); + } else if (parameter instanceof PathParameter) { + parsePathParameters(parameter, request.getRest()); + } else if (parameter instanceof HeaderParameter) { + parseHeaderParameters(parameter, request.getHeaders()); + } else if (parameter instanceof CookieParameter) { + parseCookieParameters(parameter, request.getHeaders()); + } + }); + } + + private void parsePathParameters(Parameter parameter, List rests) { + PathParameter pathParameter = (PathParameter) parameter; + rests.add(new KeyValue(pathParameter.getName(), "", getDefaultStringValue(parameter.getDescription()))); + } + + private String getDefaultStringValue(String val) { + return StringUtils.isBlank(val) ? "" : val; + } + + private void parseCookieParameters(Parameter parameter, List headers) { + CookieParameter cookieParameter = (CookieParameter) parameter; + addCookie(headers, cookieParameter.getName(), "", getDefaultStringValue(cookieParameter.getDescription())); + } + + private void parseHeaderParameters(Parameter parameter, List headers) { + HeaderParameter headerParameter = (HeaderParameter) parameter; + addHeader(headers, headerParameter.getName(), "", getDefaultStringValue(headerParameter.getDescription())); + } + + private HttpResponse parseResponse(ApiResponses responses) { + HttpResponse msResponse = new HttpResponse(); + msResponse.setBody(new Body()); + msResponse.setHeaders(new ArrayList<>()); + msResponse.setType(RequestType.HTTP); + // todo 状态码要调整? + msResponse.setStatusCode(new ArrayList<>()); + if (responses != null) { + responses.forEach((responseCode, response) -> { + msResponse.getStatusCode().add(new KeyValue(responseCode, responseCode)); + parseResponseHeader(response, msResponse.getHeaders()); + parseResponseBody(response, msResponse.getBody()); + }); + } + return msResponse; + } + + private void parseResponseHeader(ApiResponse response, List msHeaders) { + Map headers = response.getHeaders(); + if (headers != null) { + headers.forEach((k, v) -> { + msHeaders.add(new KeyValue(k, "", v.getDescription())); + }); + } + } + + private void parseResponseBody(ApiResponse response, Body body) { + body.setRaw(response.getDescription()); + Content content = response.getContent(); + if (content == null) { + body.setType(Body.RAW); + body.setRaw(response.getDescription()); + } else { + parseBody(response.getContent(), body); + } + } + + private void parseRequestBody(RequestBody requestBody, Body body) { + if (requestBody == null) { + return; + } + parseBody(requestBody.getContent(), body); + } + + private void parseBody(Content content, Body body) { + if (content == null) { + return; + } + // 多个contentType ,优先取json + String contentType = ""; + MediaType mediaType = content.get(contentType); + if (mediaType == null) { + Set contentTypes = content.keySet(); + contentType = contentTypes.iterator().next(); + if (StringUtils.isBlank(contentType)) { + return; + } + mediaType = content.get(contentType); + } else { + contentType = org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + } + + Set refSet = new HashSet<>(); + Map binaryKeyMap = new HashMap(); + Schema schema = mediaType.getSchema(); + Object bodyData = parseSchema(schema, refSet, binaryKeyMap); + + if (bodyData == null) { + return; + } + + body.setType(getBodyType(contentType)); + + if (StringUtils.equals(contentType, org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE)) { + parseKvBody(schema, body, bodyData, binaryKeyMap); + } else if (StringUtils.equals(contentType, org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)) { + body.setRaw(bodyData.toString()); + } else if (StringUtils.equals(contentType, org.springframework.http.MediaType.APPLICATION_JSON_VALUE)) { + body.setRaw(bodyData.toString()); + } else if (StringUtils.equals(contentType, org.springframework.http.MediaType.APPLICATION_XML_VALUE)) { + body.setRaw(parseXmlBody(schema, bodyData)); + } else if (StringUtils.equals(contentType, org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE)) { + parseKvBody(schema, body, bodyData, binaryKeyMap); + } else { + body.setRaw(bodyData.toString()); + } + } + + private void parseKvBody(Schema schema, Body body, Object data, Map binaryKeyMap) { + if (data instanceof JSONObject) { + ((JSONObject) data).forEach((k, v) -> { + KeyValue kv = new KeyValue(k, v.toString()); + if (binaryKeyMap.keySet().contains(k)) { + kv.setDescription(binaryKeyMap.get(k)); + kv.setType("file"); + } + body.getKvs().add(kv); + }); + } else { + KeyValue kv = new KeyValue(schema.getName(), data.toString(), schema.getDescription()); + if (binaryKeyMap.keySet().contains(schema.getName())) { + kv.setDescription(binaryKeyMap.get(schema.getDescription())); + kv.setType("file"); + } + body.getKvs().add(kv); + } + } + + private String parseXmlBody(Schema schema, Object data) { + if (data instanceof JSONObject) { + return XMLUtils.jsonToXmlStr((JSONObject) data); + } else { + JSONObject object = new JSONObject(); + object.put(schema.getName(), getDefaultValueByPropertyType(schema)); + return XMLUtils.jsonToXmlStr(object); + } + } + + private Schema getModelByRef(String ref) { + if (StringUtils.isBlank(ref)) { + return null; + } + if (ref.split("/").length > 3) { + ref = ref.replace("#/components/schemas/", ""); + } + return this.components.getSchemas().get(ref); + } + + private Object parseSchema(Schema schema, Set refSet, Map binaryKeyMap) { + if (StringUtils.isNotBlank(schema.get$ref())) { + refSet.add(schema.get$ref()); + Object propertiesResult = parseSchemaProperties(getModelByRef(schema.get$ref()), refSet, binaryKeyMap); + return propertiesResult == null ? getDefaultValueByPropertyType(schema) : propertiesResult; + } else if (schema instanceof ArraySchema) { + JSONArray jsonArray = new JSONArray(); + Schema items = ((ArraySchema) schema).getItems(); + parseSchema(items, refSet, binaryKeyMap); + jsonArray.add(parseSchema(items, refSet, binaryKeyMap)); + return jsonArray; + } else if (schema instanceof BinarySchema) { + binaryKeyMap.put(schema.getName(), schema.getDescription()); + return getDefaultValueByPropertyType(schema); + } else { + Object propertiesResult = parseSchemaProperties(schema, refSet, binaryKeyMap); + return propertiesResult == null ? getDefaultValueByPropertyType(schema) : propertiesResult; + } + } + + private Object parseSchemaProperties(Schema schema, Set refSet, Map binaryKeyMap) { + Map properties = schema.getProperties(); + if (MapUtils.isEmpty(properties)) { + return null; + } + JSONObject jsonObject = new JSONObject(); + properties.forEach((key, value) -> { + jsonObject.put(key, parseSchema(value, refSet, binaryKeyMap)); + }); + return jsonObject; + } + + private Object getDefaultValueByPropertyType(Schema value) { + Object example = value.getExample(); + if (value instanceof IntegerSchema) { + return example == null ? 0 : example; + } else if (value instanceof NumberSchema) { + return example == null ? 0.0 : example; + } else {// todo 其他类型? + return getDefaultStringValue(value.getDescription()); + } + } + + private void parseQueryParameters(Parameter parameter, List arguments) { + QueryParameter queryParameter = (QueryParameter) parameter; + arguments.add(new KeyValue(queryParameter.getName(), "", getDefaultStringValue(queryParameter.getDescription()))); + } +} diff --git a/backend/src/main/java/io/metersphere/commons/utils/XMLUtils.java b/backend/src/main/java/io/metersphere/commons/utils/XMLUtils.java new file mode 100644 index 0000000000..10eb61872c --- /dev/null +++ b/backend/src/main/java/io/metersphere/commons/utils/XMLUtils.java @@ -0,0 +1,47 @@ +package io.metersphere.commons.utils; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import org.apache.commons.lang3.StringUtils; + +import java.util.Map; +import java.util.Set; + +public class XMLUtils { + + private static void jsonToXmlStr(JSONObject jObj, StringBuffer buffer) { + Set> se = jObj.entrySet(); + for (Map.Entry en : se) { + if ("com.alibaba.fastjson.JSONObject".equals(en.getValue().getClass().getName())) { + buffer.append("<").append(en.getKey()).append(">"); + JSONObject jo = jObj.getJSONObject(en.getKey()); + jsonToXmlStr(jo, buffer); + buffer.append(""); + } else if ("com.alibaba.fastjson.JSONArray".equals(en.getValue().getClass().getName())) { + JSONArray jarray = jObj.getJSONArray(en.getKey()); + for (int i = 0; i < jarray.size(); i++) { + buffer.append("<").append(en.getKey()).append(">"); + if (StringUtils.isNotBlank(jarray.getString(i))) { + JSONObject jsonobject = jarray.getJSONObject(i); + jsonToXmlStr(jsonobject, buffer); + buffer.append(""); + } + } + } else if ("java.lang.String".equals(en.getValue().getClass().getName())) { + buffer.append("<").append(en.getKey()).append(">").append(en.getValue()); + buffer.append(""); + } + } + } + + public static String jsonToXmlStr(JSONObject jObj) { + StringBuffer buffer = new StringBuffer(); + buffer.append(""); + try { + jsonToXmlStr(jObj, buffer); + } catch (Exception e) { + LogUtil.error(e.getMessage(), e); + } + return buffer.toString(); + } +} diff --git a/backend/src/main/java/io/metersphere/controller/TestController.java b/backend/src/main/java/io/metersphere/controller/TestController.java index 2e2e761937..b5ef022631 100644 --- a/backend/src/main/java/io/metersphere/controller/TestController.java +++ b/backend/src/main/java/io/metersphere/controller/TestController.java @@ -5,6 +5,7 @@ import io.metersphere.base.domain.User; import io.metersphere.commons.utils.SessionUtils; import io.metersphere.controller.handler.annotation.NoResultHolder; import org.apache.commons.lang3.StringUtils; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -35,6 +36,15 @@ public class TestController { return jsonObject; } + @PostMapping(value = "/wwwform", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public Object testWwwForm(String id, User user, String name) { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("id", id); + jsonObject.put("user", user.getName()); + jsonObject.put("name", name); + return jsonObject; + } + @NoResultHolder @GetMapping(value = "/xml") public String getXmlString() { diff --git a/frontend/src/i18n/en-US.js b/frontend/src/i18n/en-US.js index bfd52faaa2..1ffe3fd1d3 100644 --- a/frontend/src/i18n/en-US.js +++ b/frontend/src/i18n/en-US.js @@ -795,7 +795,7 @@ export default { export_tip: "Export Tip", ms_tip: "Support for MeterSphere JSON format", ms_export_tip: "Export jSON-formatted files via MeterSphere website or browser plug-ins", - swagger_tip: "Only Swagger2.x json files are supported", + swagger_tip: "Swagger 2.0 and 3.0 json files are supported", postman_tip: "Only Postman Collection V2.1 json files are supported", postman_export_tip: "Export the test collection by Postman", swagger_export_tip: "Export jSON-formatted files via Swagger website", diff --git a/frontend/src/i18n/zh-CN.js b/frontend/src/i18n/zh-CN.js index cf5240928b..25ff5e99db 100644 --- a/frontend/src/i18n/zh-CN.js +++ b/frontend/src/i18n/zh-CN.js @@ -797,7 +797,7 @@ export default { ms_tip: "支持 Metersphere json 格式", ms_export_tip: "通过 Metersphere 接口测试页面或者浏览器插件导出 json 格式文件", postman_tip: "只支持 Postman Collection v2.1 格式的 json 文件", - swagger_tip: "只支持 Swagger 2.x 版本的 json 文件", + swagger_tip: "支持 Swagger 2.0 与 3.0 版本的 json 文件", post_export_tip: "通过 Postman 导出测试集合", swagger_export_tip: "通过 Swagger 页面导出", suffixFormatErr: "文件格式不符合要求", diff --git a/frontend/src/i18n/zh-TW.js b/frontend/src/i18n/zh-TW.js index a0aee26147..e93f1f20d4 100644 --- a/frontend/src/i18n/zh-TW.js +++ b/frontend/src/i18n/zh-TW.js @@ -796,7 +796,7 @@ export default { ms_tip: "支持 Metersphere json 格式", ms_export_tip: "通過 Metersphere 接口測試頁面或者瀏覽器插件導出 json 格式文件", postman_tip: "只支持 Postman Collection v2.1 格式的 json 文件", - swagger_tip: "只支持 Swagger 2.x 版本的 json 文件", + swagger_tip: "支持 Swagger 2.0 與 3.0版本的 json 文件", post_export_tip: "通過 Postman 導出測試集合", swagger_export_tip: "通過 Swagger 頁面導出", suffixFormatErr: "文件格式不符合要求",