1 /*
2  *
3  *  * Copyright 2019-2020 the original author or authors.
4  *  *
5  *  * Licensed under the Apache License, Version 2.0 (the "License");
6  *  * you may not use this file except in compliance with the License.
7  *  * You may obtain a copy of the License at
8  *  *
9  *  *      https://www.apache.org/licenses/LICENSE-2.0
10  *  *
11  *  * Unless required by applicable law or agreed to in writing, software
12  *  * distributed under the License is distributed on an "AS IS" BASIS,
13  *  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14  *  * See the License for the specific language governing permissions and
15  *  * limitations under the License.
16  *
17  */

18
19 package org.springdoc.core;
20
21 import java.lang.reflect.Method;
22 import java.lang.reflect.ParameterizedType;
23 import java.lang.reflect.Type;
24 import java.util.ArrayList;
25 import java.util.Arrays;
26 import java.util.LinkedHashMap;
27 import java.util.List;
28 import java.util.Map;
29 import java.util.Optional;
30 import java.util.Set;
31 import java.util.stream.Collectors;
32 import java.util.stream.Stream;
33
34 import com.fasterxml.jackson.annotation.JsonView;
35 import io.swagger.v3.core.util.AnnotationsUtils;
36 import io.swagger.v3.oas.models.Components;
37 import io.swagger.v3.oas.models.Operation;
38 import io.swagger.v3.oas.models.media.ComposedSchema;
39 import io.swagger.v3.oas.models.media.Content;
40 import io.swagger.v3.oas.models.media.Schema;
41 import io.swagger.v3.oas.models.responses.ApiResponse;
42 import io.swagger.v3.oas.models.responses.ApiResponses;
43 import org.apache.commons.lang3.ArrayUtils;
44 import org.apache.commons.lang3.StringUtils;
45
46 import org.springframework.core.MethodParameter;
47 import org.springframework.core.ResolvableType;
48 import org.springframework.core.annotation.AnnotatedElementUtils;
49 import org.springframework.http.HttpStatus;
50 import org.springframework.util.CollectionUtils;
51 import org.springframework.web.bind.annotation.ExceptionHandler;
52 import org.springframework.web.bind.annotation.RequestMapping;
53 import org.springframework.web.bind.annotation.ResponseStatus;
54 import org.springframework.web.method.HandlerMethod;
55
56 import static org.springdoc.core.Constants.DEFAULT_DESCRIPTION;
57 import static org.springdoc.core.converters.ConverterUtils.isResponseTypeWrapper;
58
59 @SuppressWarnings("rawtypes")
60 public class GenericResponseBuilder {
61
62     private final Map<String, ApiResponse> genericMapResponse = new LinkedHashMap<>();
63
64     private final OperationBuilder operationBuilder;
65
66     private final List<ReturnTypeParser> returnTypeParsers;
67
68     private final SpringDocConfigProperties springDocConfigProperties;
69
70     private final PropertyResolverUtils propertyResolverUtils;
71
72     public GenericResponseBuilder(OperationBuilder operationBuilder, List<ReturnTypeParser> returnTypeParsers,
73             SpringDocConfigProperties springDocConfigProperties,
74             PropertyResolverUtils propertyResolverUtils) {
75         super();
76         this.operationBuilder = operationBuilder;
77         this.returnTypeParsers = returnTypeParsers;
78         this.springDocConfigProperties = springDocConfigProperties;
79         this.propertyResolverUtils = propertyResolverUtils;
80     }
81
82     public ApiResponses build(Components components, HandlerMethod handlerMethod, Operation operation,
83             MethodAttributes methodAttributes) {
84         ApiResponses apiResponses = new ApiResponses();
85         genericMapResponse.forEach(apiResponses::addApiResponse);
86         //Then use the apiResponses from documentation
87         ApiResponses apiResponsesFromDoc = operation.getResponses();
88         if (!CollectionUtils.isEmpty(apiResponsesFromDoc))
89             apiResponsesFromDoc.forEach(apiResponses::addApiResponse);
90         // for each one build ApiResponse and add it to existing responses
91         // Fill api Responses
92         computeResponse(components, handlerMethod.getReturnType(), apiResponses, methodAttributes, false);
93         return apiResponses;
94     }
95
96     public void buildGenericResponse(Components components, Map<String, Object> findControllerAdvice) {
97         // ControllerAdvice
98         List<Method> methods = getMethods(findControllerAdvice);
99         // for each one build ApiResponse and add it to existing responses
100         for (Method method : methods) {
101             if (!operationBuilder.isHidden(method)) {
102                 RequestMapping reqMappringMethod = AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class);
103                 String[] methodProduces = { springDocConfigProperties.getDefaultProducesMediaType() };
104                 if (reqMappringMethod != null)
105                     methodProduces = reqMappringMethod.produces();
106                 Map<String, ApiResponse> apiResponses = computeResponse(components, new MethodParameter(method, -1), new ApiResponses(),
107                         new MethodAttributes(methodProduces, springDocConfigProperties.getDefaultConsumesMediaType(), springDocConfigProperties.getDefaultProducesMediaType()), true);
108                 apiResponses.forEach(genericMapResponse::put);
109             }
110         }
111     }
112
113     private List<Method> getMethods(Map<String, Object> findControllerAdvice) {
114         List<Method> methods = new ArrayList<>();
115         for (Map.Entry<String, Object> entry : findControllerAdvice.entrySet()) {
116             Object controllerAdvice = entry.getValue();
117             // get all methods with annotation @ExceptionHandler
118             Class<?> objClz = controllerAdvice.getClass();
119             if (org.springframework.aop.support.AopUtils.isAopProxy(controllerAdvice))
120                 objClz = org.springframework.aop.support.AopUtils.getTargetClass(controllerAdvice);
121             Arrays.stream(objClz.getDeclaredMethods()).filter(m -> m.isAnnotationPresent(ExceptionHandler.class)).forEach(methods::add);
122         }
123         return methods;
124     }
125
126     private Map<String, ApiResponse> computeResponse(Components components, MethodParameter methodParameter, ApiResponses apiResponsesOp,
127             MethodAttributes methodAttributes, boolean isGeneric) {
128         // Parsing documentation, if present
129         Set<io.swagger.v3.oas.annotations.responses.ApiResponse> responsesArray = getApiResponses(methodParameter.getMethod());
130         if (!responsesArray.isEmpty()) {
131             methodAttributes.setWithApiResponseDoc(true);
132             if (!springDocConfigProperties.isOverrideWithGenericResponse())
133                 for (String key : genericMapResponse.keySet())
134                     apiResponsesOp.remove(key);
135             for (io.swagger.v3.oas.annotations.responses.ApiResponse apiResponseAnnotations : responsesArray) {
136                 ApiResponse apiResponse = new ApiResponse();
137                 if (StringUtils.isNotBlank(apiResponseAnnotations.ref())) {
138                     apiResponse.$ref(apiResponseAnnotations.ref());
139                     apiResponsesOp.addApiResponse(apiResponseAnnotations.responseCode(), apiResponse);
140                     continue;
141                 }
142                 apiResponse.setDescription(propertyResolverUtils.resolve(apiResponseAnnotations.description()));
143                 io.swagger.v3.oas.annotations.media.Content[] contentdoc = apiResponseAnnotations.content();
144                 buildContentFromDoc(components, apiResponsesOp, methodAttributes,
145                         apiResponseAnnotations, apiResponse, contentdoc);
146                 Map<String, Object> extensions = AnnotationsUtils.getExtensions(apiResponseAnnotations.extensions());
147                 if (!CollectionUtils.isEmpty(extensions))
148                     apiResponse.extensions(extensions);
149                 AnnotationsUtils.getHeaders(apiResponseAnnotations.headers(), methodAttributes.getJsonViewAnnotation())
150                         .ifPresent(apiResponse::headers);
151                 apiResponsesOp.addApiResponse(apiResponseAnnotations.responseCode(), apiResponse);
152             }
153         }
154         buildApiResponses(components, methodParameter, apiResponsesOp, methodAttributes, isGeneric);
155         return apiResponsesOp;
156     }
157
158     private void buildContentFromDoc(Components components, ApiResponses apiResponsesOp,
159             MethodAttributes methodAttributes,
160             io.swagger.v3.oas.annotations.responses.ApiResponse apiResponseAnnotations, ApiResponse apiResponse,
161             io.swagger.v3.oas.annotations.media.Content[] contentdoc) {
162         Optional<Content> optionalContent = SpringDocAnnotationsUtils.getContent(contentdoc, new String[0],
163                 methodAttributes.getMethodProduces(), null, components, methodAttributes.getJsonViewAnnotation());
164         if (apiResponsesOp.containsKey(apiResponseAnnotations.responseCode())) {
165             // Merge with the existing content
166             Content existingContent = apiResponsesOp.get(apiResponseAnnotations.responseCode()).getContent();
167             if (optionalContent.isPresent() && existingContent != null) {
168                 Content newContent = optionalContent.get();
169                 if (methodAttributes.isMethodOverloaded()) {
170                     Arrays.stream(methodAttributes.getMethodProduces()).filter(mediaTypeStr -> (newContent.get(mediaTypeStr) != null)).forEach(mediaTypeStr -> mergeSchema(existingContent, newContent.get(mediaTypeStr).getSchema(), mediaTypeStr));
171                     apiResponse.content(existingContent);
172                 }
173                 else
174                     apiResponse.content(newContent);
175             }
176         }
177         else {
178             optionalContent.ifPresent(apiResponse::content);
179         }
180     }
181
182     private void buildApiResponses(Components components, MethodParameter methodParameter, ApiResponses apiResponsesOp,
183             MethodAttributes methodAttributes, boolean isGeneric) {
184         if (!CollectionUtils.isEmpty(apiResponsesOp) && (apiResponsesOp.size() != genericMapResponse.size() || isGeneric)) {
185             // API Responses at operation and @ApiResponse annotation
186             for (Map.Entry<String, ApiResponse> entry : apiResponsesOp.entrySet()) {
187                 String httpCode = entry.getKey();
188                 ApiResponse apiResponse = entry.getValue();
189                 buildApiResponses(components, methodParameter, apiResponsesOp, methodAttributes, httpCode, apiResponse,
190                         isGeneric);
191             }
192         }
193         else {
194             // Use response parameters with no description filled - No documentation
195             // available
196             String httpCode = evaluateResponseStatus(methodParameter.getMethod(), methodParameter.getMethod().getClass(), isGeneric);
197             ApiResponse apiResponse = genericMapResponse.containsKey(httpCode) ? genericMapResponse.get(httpCode)
198                     : new ApiResponse();
199             if (httpCode != null)
200                 buildApiResponses(components, methodParameter, apiResponsesOp, methodAttributes, httpCode, apiResponse,
201                         isGeneric);
202         }
203     }
204
205     private Set<io.swagger.v3.oas.annotations.responses.ApiResponse> getApiResponses(Method method) {
206         Class<?> declaringClass = method.getDeclaringClass();
207
208         Set<io.swagger.v3.oas.annotations.responses.ApiResponses> apiResponsesDoc = AnnotatedElementUtils
209                 .findAllMergedAnnotations(method, io.swagger.v3.oas.annotations.responses.ApiResponses.class);
210         Set<io.swagger.v3.oas.annotations.responses.ApiResponse> responses = apiResponsesDoc.stream()
211                 .flatMap(x -> Stream.of(x.value())).collect(Collectors.toSet());
212
213         Set<io.swagger.v3.oas.annotations.responses.ApiResponses> apiResponsesDocDeclaringClass = AnnotatedElementUtils
214                 .findAllMergedAnnotations(declaringClass, io.swagger.v3.oas.annotations.responses.ApiResponses.class);
215         responses.addAll(
216                 apiResponsesDocDeclaringClass.stream().flatMap(x -> Stream.of(x.value())).collect(Collectors.toSet()));
217
218         Set<io.swagger.v3.oas.annotations.responses.ApiResponse> apiResponseDoc = AnnotatedElementUtils
219                 .findMergedRepeatableAnnotations(method, io.swagger.v3.oas.annotations.responses.ApiResponse.class);
220         responses.addAll(apiResponseDoc);
221
222         Set<io.swagger.v3.oas.annotations.responses.ApiResponse> apiResponseDocDeclaringClass = AnnotatedElementUtils
223                 .findMergedRepeatableAnnotations(declaringClass,
224                         io.swagger.v3.oas.annotations.responses.ApiResponse.class);
225         responses.addAll(apiResponseDocDeclaringClass);
226
227         return responses;
228     }
229
230     private Content buildContent(Components components, MethodParameter methodParameter, String[] methodProduces, JsonView jsonView) {
231         Content content = new Content();
232         Type returnType = getReturnType(methodParameter);
233         // if void, no content
234         if (isVoid(returnType))
235             return null;
236         if (ArrayUtils.isNotEmpty(methodProduces)) {
237             Schema<?> schemaN = calculateSchema(components, returnType, jsonView);
238             if (schemaN != null) {
239                 io.swagger.v3.oas.models.media.MediaType mediaType = new io.swagger.v3.oas.models.media.MediaType();
240                 mediaType.setSchema(schemaN);
241                 // Fill the content
242                 setContent(methodProduces, content, mediaType);
243             }
244         }
245         return content;
246     }
247
248     private Type getReturnType(MethodParameter methodParameter) {
249         Type returnType = Object.class;
250         for (ReturnTypeParser returnTypeParser : returnTypeParsers) {
251             if (returnType.getTypeName().equals(Object.class.getTypeName())) {
252                 returnType = returnTypeParser.getReturnType(methodParameter);
253             }
254             else
255                 break;
256         }
257
258         return returnType;
259     }
260
261     public Schema calculateSchema(Components components, Type returnType, JsonView jsonView) {
262         return !isVoid(returnType) ? SpringDocAnnotationsUtils.extractSchema(components, returnType, jsonView) : null;
263     }
264
265     private void setContent(String[] methodProduces, Content content,
266             io.swagger.v3.oas.models.media.MediaType mediaType) {
267         Arrays.stream(methodProduces).forEach(mediaTypeStr -> content.addMediaType(mediaTypeStr, mediaType));
268     }
269
270     private void buildApiResponses(Components components, MethodParameter methodParameter, ApiResponses apiResponsesOp,
271             MethodAttributes methodAttributes, String httpCode, ApiResponse apiResponse, boolean isGeneric) {
272         // No documentation
273         if (StringUtils.isBlank(apiResponse.get$ref())) {
274             if (apiResponse.getContent() == null) {
275                 Content content = buildContent(components, methodParameter, methodAttributes.getMethodProduces(),
276                         methodAttributes.getJsonViewAnnotation());
277                 apiResponse.setContent(content);
278             }
279             else if (CollectionUtils.isEmpty(apiResponse.getContent()))
280                 apiResponse.setContent(null);
281             if (StringUtils.isBlank(apiResponse.getDescription()))
282                 apiResponse.setDescription(DEFAULT_DESCRIPTION);
283         }
284         if (apiResponse.getContent() != null
285                 && ((isGeneric || methodAttributes.isMethodOverloaded()) && methodAttributes.isNoApiResponseDoc())) {
286             // Merge with existing schema
287             Content existingContent = apiResponse.getContent();
288             Schema<?> schemaN = calculateSchema(components, methodParameter.getGenericParameterType(),
289                     methodAttributes.getJsonViewAnnotation());
290             if (schemaN != null && ArrayUtils.isNotEmpty(methodAttributes.getMethodProduces()))
291                 Arrays.stream(methodAttributes.getMethodProduces()).forEach(mediaTypeStr -> mergeSchema(existingContent, schemaN, mediaTypeStr));
292         }
293         apiResponsesOp.addApiResponse(httpCode, apiResponse);
294     }
295
296     private void mergeSchema(Content existingContent, Schema<?> schemaN, String mediaTypeStr) {
297         if (existingContent.containsKey(mediaTypeStr)) {
298             io.swagger.v3.oas.models.media.MediaType mediaType = existingContent.get(mediaTypeStr);
299             if (!schemaN.equals(mediaType.getSchema())) {
300                 // Merge the two schemas for the same mediaType
301                 Schema firstSchema = mediaType.getSchema();
302                 ComposedSchema schemaObject;
303                 if (firstSchema instanceof ComposedSchema) {
304                     schemaObject = (ComposedSchema) firstSchema;
305                     List<Schema> listOneOf = schemaObject.getOneOf();
306                     if (!CollectionUtils.isEmpty(listOneOf) && !listOneOf.contains(schemaN))
307                         schemaObject.addOneOfItem(schemaN);
308                 }
309                 else {
310                     schemaObject = new ComposedSchema();
311                     schemaObject.addOneOfItem(schemaN);
312                     schemaObject.addOneOfItem(firstSchema);
313                 }
314                 mediaType.setSchema(schemaObject);
315                 existingContent.addMediaType(mediaTypeStr, mediaType);
316             }
317         }
318         else
319             // Add the new schema for a different mediaType
320             existingContent.addMediaType(mediaTypeStr, new io.swagger.v3.oas.models.media.MediaType().schema(schemaN));
321     }
322
323     private String evaluateResponseStatus(Method method, Class<?> beanType, boolean isGeneric) {
324         String responseStatus = null;
325         ResponseStatus annotation = AnnotatedElementUtils.findMergedAnnotation(method, ResponseStatus.class);
326         if (annotation == null && beanType != null)
327             annotation = AnnotatedElementUtils.findMergedAnnotation(beanType, ResponseStatus.class);
328         if (annotation != null)
329             responseStatus = String.valueOf(annotation.code().value());
330         if (annotation == null && !isGeneric)
331             responseStatus = String.valueOf(HttpStatus.OK.value());
332         return responseStatus;
333     }
334
335     private boolean isVoid(Type returnType) {
336         boolean result = false;
337         if (Void.TYPE.equals(returnType))
338             result = true;
339         else if (returnType instanceof ParameterizedType) {
340             Type[] types = ((ParameterizedType) returnType).getActualTypeArguments();
341             if (types != null && isResponseTypeWrapper(ResolvableType.forType(returnType).getRawClass()))
342                 return isVoid(types[0]);
343         }
344         if (Void.class.equals(returnType))
345             result = true;
346         return result;
347     }
348 }
349