View Javadoc
1   /*
2    * Copyright 2019-2022 the original author or authors.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *      http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  
17  package org.bremersee.exception.servlet;
18  
19  import static java.util.Objects.requireNonNullElse;
20  import static org.springframework.core.annotation.AnnotationUtils.findAnnotation;
21  import static org.springframework.util.ObjectUtils.isEmpty;
22  
23  import com.fasterxml.jackson.databind.ObjectMapper;
24  import com.fasterxml.jackson.dataformat.xml.XmlMapper;
25  import java.time.OffsetDateTime;
26  import java.time.ZoneOffset;
27  import java.util.List;
28  import java.util.Map;
29  import java.util.function.Function;
30  import jakarta.servlet.http.HttpServletRequest;
31  import jakarta.servlet.http.HttpServletResponse;
32  import lombok.AccessLevel;
33  import lombok.Getter;
34  import lombok.Setter;
35  import lombok.extern.slf4j.Slf4j;
36  import org.bremersee.exception.RestApiExceptionConstants;
37  import org.bremersee.exception.RestApiExceptionMapper;
38  import org.bremersee.exception.RestApiResponseType;
39  import org.bremersee.exception.model.RestApiException;
40  import org.springframework.http.HttpStatus;
41  import org.springframework.http.MediaType;
42  import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
43  import org.springframework.http.server.ServletServerHttpRequest;
44  import org.springframework.lang.NonNull;
45  import org.springframework.util.AntPathMatcher;
46  import org.springframework.util.PathMatcher;
47  import org.springframework.web.bind.annotation.RestController;
48  import org.springframework.web.method.HandlerMethod;
49  import org.springframework.web.servlet.HandlerExceptionResolver;
50  import org.springframework.web.servlet.ModelAndView;
51  import org.springframework.web.servlet.view.AbstractView;
52  import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
53  import org.springframework.web.servlet.view.xml.MappingJackson2XmlView;
54  import org.springframework.web.util.WebUtils;
55  
56  /**
57   * The api exception resolver.
58   *
59   * @author Christian Bremer
60   */
61  @Slf4j
62  public class ApiExceptionResolver implements HandlerExceptionResolver {
63  
64    /**
65     * The constant MODEL_KEY.
66     */
67    protected static final String MODEL_KEY = "error";
68  
69    @Getter(AccessLevel.PROTECTED)
70    private final List<String> apiPaths;
71  
72    @Getter(AccessLevel.PROTECTED)
73    @Setter
74    private PathMatcher pathMatcher = new AntPathMatcher();
75  
76    @Getter(AccessLevel.PROTECTED)
77    @Setter
78    private Function<HttpServletRequest, String> restApiExceptionIdProvider;
79  
80    @Getter(AccessLevel.PROTECTED)
81    private final RestApiExceptionMapper exceptionMapper;
82  
83    @Getter(AccessLevel.PROTECTED)
84    private final ObjectMapper objectMapper;
85  
86    @Getter(AccessLevel.PROTECTED)
87    private final XmlMapper xmlMapper;
88  
89    /**
90     * Instantiates a new api exception resolver.
91     *
92     * @param apiPaths the api paths
93     * @param exceptionMapper the exception mapper
94     */
95    public ApiExceptionResolver(
96        List<String> apiPaths,
97        RestApiExceptionMapper exceptionMapper) {
98      this(apiPaths, exceptionMapper, new Jackson2ObjectMapperBuilder());
99    }
100 
101   /**
102    * Instantiates a new api exception resolver.
103    *
104    * @param apiPaths the api paths
105    * @param exceptionMapper the exception mapper
106    * @param objectMapperBuilder the object mapper builder
107    */
108   public ApiExceptionResolver(
109       List<String> apiPaths,
110       RestApiExceptionMapper exceptionMapper,
111       Jackson2ObjectMapperBuilder objectMapperBuilder) {
112     this(
113         apiPaths,
114         exceptionMapper,
115         objectMapperBuilder.build(),
116         objectMapperBuilder.createXmlMapper(true).build());
117   }
118 
119   /**
120    * Instantiates a new api exception resolver.
121    *
122    * @param apiPaths the api paths
123    * @param exceptionMapper the exception mapper
124    * @param objectMapper the object mapper
125    * @param xmlMapper the xml mapper
126    */
127   public ApiExceptionResolver(
128       List<String> apiPaths,
129       RestApiExceptionMapper exceptionMapper,
130       ObjectMapper objectMapper,
131       XmlMapper xmlMapper) {
132     this.apiPaths = apiPaths;
133     this.exceptionMapper = exceptionMapper;
134     this.objectMapper = objectMapper;
135     this.xmlMapper = xmlMapper;
136   }
137 
138   @Override
139   public ModelAndView resolveException(
140       @NonNull HttpServletRequest request,
141       @NonNull HttpServletResponse response,
142       Object handler,
143       @NonNull Exception ex) {
144 
145     if (!isExceptionHandlerResponsible(request, handler)) {
146       return null;
147     }
148 
149     RestApiException payload = exceptionMapper.build(ex, request.getRequestURI(), handler);
150     if (!isEmpty(restApiExceptionIdProvider)) {
151       payload.setId(restApiExceptionIdProvider.apply(request));
152     }
153 
154     ServletServerHttpRequest httpRequest = new ServletServerHttpRequest(request);
155     List<MediaType> accepted = httpRequest.getHeaders().getAccept();
156     RestApiResponseType responseType = RestApiResponseType.detectByAccepted(accepted);
157 
158     ModelAndView modelAndView;
159     switch (responseType) {
160       case JSON:
161         MappingJackson2JsonView mjv = new MappingJackson2JsonView(objectMapper);
162         mjv.setContentType(responseType.getContentTypeValue());
163         mjv.setPrettyPrint(true);
164         mjv.setModelKey(MODEL_KEY);
165         mjv.setExtractValueFromSingleKeyModel(true); // removes the MODEL_KEY from the output
166         modelAndView = new ModelAndView(mjv, MODEL_KEY, payload);
167         break;
168 
169       case XML:
170         MappingJackson2XmlView mxv = new MappingJackson2XmlView(xmlMapper);
171         mxv.setContentType(responseType.getContentTypeValue());
172         mxv.setPrettyPrint(true);
173         mxv.setModelKey(MODEL_KEY);
174         modelAndView = new ModelAndView(mxv, MODEL_KEY, payload);
175         break;
176 
177       default:
178         modelAndView = new ModelAndView(new EmptyView(payload, responseType.getContentTypeValue()));
179     }
180 
181     response.setContentType(responseType.getContentTypeValue());
182     int statusCode = requireNonNullElse(
183         payload.getStatus(),
184         HttpStatus.INTERNAL_SERVER_ERROR.value());
185     modelAndView.setStatus(HttpStatus.resolve(statusCode));
186     applyStatusCodeIfPossible(request, response, statusCode);
187     return modelAndView;
188   }
189 
190   /**
191    * Is this exception handler responsible.
192    *
193    * @param request the request
194    * @param handler the handler
195    * @return {@code true} if it is responsible, otherwise {@code false}
196    */
197   protected boolean isExceptionHandlerResponsible(
198       HttpServletRequest request,
199       Object handler) {
200 
201     if (!isEmpty(apiPaths)) {
202       return apiPaths.stream().anyMatch(
203           s -> pathMatcher.match(s, request.getServletPath()));
204     }
205 
206     if (isEmpty(handler)) {
207       return false;
208     }
209     Class<?> cls = handler instanceof HandlerMethod
210         ? ((HandlerMethod) handler).getBean().getClass()
211         : handler.getClass();
212     boolean result = !isEmpty(findAnnotation(cls, RestController.class));
213     if (log.isDebugEnabled()) {
214       log.debug("Is handler [" + handler + "] a rest controller? " + result);
215     }
216     return result;
217   }
218 
219   /**
220    * Apply status code if possible.
221    *
222    * @param request the request
223    * @param response the response
224    * @param statusCode the status code
225    */
226   protected final void applyStatusCodeIfPossible(
227       HttpServletRequest request,
228       HttpServletResponse response,
229       int statusCode) {
230 
231     if (!WebUtils.isIncludeRequest(request)) {
232       if (log.isDebugEnabled()) {
233         log.debug("Applying HTTP status code " + statusCode);
234       }
235       response.setStatus(statusCode);
236       request.setAttribute(WebUtils.ERROR_STATUS_CODE_ATTRIBUTE, statusCode);
237     }
238   }
239 
240   /**
241    * The empty view.
242    */
243   protected static class EmptyView extends AbstractView {
244 
245     /**
246      * The rest api exception.
247      */
248     protected final RestApiException restApiException;
249 
250     /**
251      * Instantiates a new empty view.
252      *
253      * @param payload the payload
254      * @param contentType the content type
255      */
256     protected EmptyView(RestApiException payload, String contentType) {
257       this.restApiException = payload;
258       setContentType(contentType);
259     }
260 
261     @Override
262     protected void renderMergedOutputModel(
263         @NonNull Map<String, Object> map,
264         @NonNull HttpServletRequest httpServletRequest,
265         @NonNull HttpServletResponse httpServletResponse) {
266 
267       if (!isEmpty(restApiException.getId())) {
268         httpServletResponse.addHeader(
269             RestApiExceptionConstants.ID_HEADER_NAME,
270             restApiException.getId());
271       }
272 
273       String timestamp;
274       if (!isEmpty(restApiException.getTimestamp())) {
275         timestamp = restApiException.getTimestamp()
276             .format(RestApiExceptionConstants.TIMESTAMP_FORMATTER);
277       } else {
278         timestamp = OffsetDateTime.now(ZoneOffset.UTC)
279             .format(RestApiExceptionConstants.TIMESTAMP_FORMATTER);
280       }
281       httpServletResponse.addHeader(RestApiExceptionConstants.TIMESTAMP_HEADER_NAME, timestamp);
282 
283       if (!isEmpty(restApiException.getErrorCode())) {
284         httpServletResponse.addHeader(
285             RestApiExceptionConstants.CODE_HEADER_NAME,
286             restApiException.getErrorCode());
287         httpServletResponse.addHeader(
288             RestApiExceptionConstants.CODE_INHERITED_HEADER_NAME,
289             String.valueOf(restApiException.getErrorCodeInherited()));
290       }
291 
292       if (!isEmpty(restApiException.getMessage())) {
293         httpServletResponse.addHeader(
294             RestApiExceptionConstants.MESSAGE_HEADER_NAME,
295             restApiException.getMessage());
296       }
297 
298       if (!isEmpty(restApiException.getException())) {
299         httpServletResponse.addHeader(
300             RestApiExceptionConstants.EXCEPTION_HEADER_NAME,
301             restApiException.getException());
302       }
303 
304       if (!isEmpty(restApiException.getApplication())) {
305         httpServletResponse.addHeader(
306             RestApiExceptionConstants.APPLICATION_HEADER_NAME,
307             restApiException.getApplication());
308       }
309 
310       if (!isEmpty(restApiException.getPath())) {
311         httpServletResponse.addHeader(
312             RestApiExceptionConstants.PATH_HEADER_NAME,
313             restApiException.getPath());
314       }
315     }
316 
317   }
318 
319 }