View Javadoc
1   /*
2   * Copyright 2019-2026 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.spring.boot.autoconfigure.reactive;
18  
19  import static java.util.Objects.nonNull;
20  import static java.util.Objects.requireNonNullElse;
21  import static org.springframework.util.StringUtils.hasText;
22  
23  import java.time.OffsetDateTime;
24  import java.time.ZoneOffset;
25  import java.util.List;
26  import lombok.AccessLevel;
27  import lombok.Getter;
28  import lombok.Setter;
29  import org.bremersee.exception.RestApiExceptionConstants;
30  import org.bremersee.exception.RestApiExceptionMapper;
31  import org.bremersee.exception.RestApiResponseType;
32  import org.bremersee.exception.model.RestApiException;
33  import org.jspecify.annotations.NonNull;
34  import org.springframework.boot.autoconfigure.web.WebProperties;
35  import org.springframework.boot.webflux.autoconfigure.error.AbstractErrorWebExceptionHandler;
36  import org.springframework.boot.webflux.error.ErrorAttributes;
37  import org.springframework.context.ApplicationContext;
38  import org.springframework.http.HttpStatus;
39  import org.springframework.http.MediaType;
40  import org.springframework.http.codec.ServerCodecConfigurer;
41  import org.springframework.util.AntPathMatcher;
42  import org.springframework.util.PathMatcher;
43  import org.springframework.web.reactive.function.BodyInserters;
44  import org.springframework.web.reactive.function.server.RouterFunction;
45  import org.springframework.web.reactive.function.server.RouterFunctions;
46  import org.springframework.web.reactive.function.server.ServerRequest;
47  import org.springframework.web.reactive.function.server.ServerResponse;
48  import reactor.core.publisher.Mono;
49  
50  /**
51   * The reactive api exception handler.
52   *
53   * @author Christian Bremer
54   */
55  public class ApiExceptionHandler extends AbstractErrorWebExceptionHandler {
56  
57    @Getter(AccessLevel.PROTECTED)
58    private final List<String> apiPaths;
59  
60    @Getter(AccessLevel.PROTECTED)
61    @Setter
62    private PathMatcher pathMatcher = new AntPathMatcher();
63  
64    @Getter(AccessLevel.PROTECTED)
65    private final RestApiExceptionMapper restApiExceptionMapper;
66  
67    /**
68     * Instantiates a new api exception handler.
69     *
70     * @param apiPaths the api paths
71     * @param errorAttributes the error attributes
72     * @param resources the resources
73     * @param applicationContext the application context
74     * @param serverCodecConfigurer the server codec configurer
75     * @param restApiExceptionMapper the rest api exception mapper
76     */
77    public ApiExceptionHandler(
78        List<String> apiPaths,
79        ErrorAttributes errorAttributes,
80        WebProperties.Resources resources,
81        ApplicationContext applicationContext,
82        ServerCodecConfigurer serverCodecConfigurer,
83        RestApiExceptionMapper restApiExceptionMapper) {
84  
85      super(errorAttributes, resources, applicationContext);
86      if (serverCodecConfigurer != null) {
87        setMessageReaders(serverCodecConfigurer.getReaders());
88        setMessageWriters(serverCodecConfigurer.getWriters());
89      }
90      this.apiPaths = nonNull(apiPaths) ? apiPaths : List.of();
91      this.restApiExceptionMapper = restApiExceptionMapper;
92    }
93  
94    @NonNull
95    @Override
96    protected RouterFunction<ServerResponse> getRoutingFunction(
97        @NonNull ErrorAttributes errorAttributes) {
98  
99      return RouterFunctions.route(this::isResponsibleExceptionHandler, this::renderErrorResponse);
100   }
101 
102   /**
103    * Is this exception handler responsible.
104    *
105    * @param request the request
106    * @return {@code true} if it is responsible, otherwise {@code false}
107    */
108   protected boolean isResponsibleExceptionHandler(ServerRequest request) {
109     return apiPaths.stream().anyMatch(
110         path -> getPathMatcher().match(path, request.path()));
111   }
112 
113   /**
114    * Render error response.
115    *
116    * @param request the request
117    * @return the server response
118    */
119   @NonNull
120   protected Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
121 
122     RestApiException response = getRestApiExceptionMapper()
123         .build(getError(request), request.path(), null);
124 
125     RestApiResponseType restApiResponseType = RestApiResponseType
126         .detectByAccepted(request.headers().accept());
127     if (RestApiResponseType.HEADER == restApiResponseType) {
128       return emptyWithHeaders(response, restApiResponseType.getContentType());
129     } else {
130       return ServerResponse
131           .status(requireNonNullElse(
132               response.getStatus(),
133               HttpStatus.INTERNAL_SERVER_ERROR.value()))
134           .contentType(restApiResponseType.getContentType())
135           .body(BodyInserters.fromValue(response));
136     }
137   }
138 
139   private Mono<ServerResponse> emptyWithHeaders(
140       RestApiException response,
141       MediaType contentType) {
142 
143     ServerResponse.BodyBuilder builder = ServerResponse
144         .status(requireNonNullElse(
145             response.getStatus(),
146             HttpStatus.INTERNAL_SERVER_ERROR.value()));
147 
148     if (hasText(response.getId())) {
149       builder = builder.header(RestApiExceptionConstants.ID_HEADER_NAME, response.getId());
150     }
151 
152     String timestamp;
153     if (nonNull(response.getTimestamp())) {
154       timestamp = response.getTimestamp()
155           .format(RestApiExceptionConstants.TIMESTAMP_FORMATTER);
156     } else {
157       timestamp = OffsetDateTime.now(ZoneOffset.UTC)
158           .format(RestApiExceptionConstants.TIMESTAMP_FORMATTER);
159     }
160     builder = builder.header(RestApiExceptionConstants.TIMESTAMP_HEADER_NAME, timestamp);
161 
162     if (hasText(response.getErrorCode())) {
163       builder = builder.header(
164           RestApiExceptionConstants.CODE_HEADER_NAME,
165           response.getErrorCode());
166       builder = builder.header(
167           RestApiExceptionConstants.CODE_INHERITED_HEADER_NAME,
168           String.valueOf(response.getErrorCodeInherited()));
169     }
170 
171     if (hasText(response.getMessage())) {
172       builder = builder.header(
173           RestApiExceptionConstants.MESSAGE_HEADER_NAME,
174           response.getMessage());
175     }
176 
177     if (hasText(response.getException())) {
178       builder = builder.header(
179           RestApiExceptionConstants.EXCEPTION_HEADER_NAME,
180           response.getException());
181     }
182 
183     if (hasText(response.getApplication())) {
184       builder = builder.header(
185           RestApiExceptionConstants.APPLICATION_HEADER_NAME,
186           response.getApplication());
187     }
188 
189     if (hasText(response.getPath())) {
190       builder = builder.header(
191           RestApiExceptionConstants.PATH_HEADER_NAME,
192           response.getPath());
193     }
194 
195     return builder
196         .contentType(contentType)
197         .body(BodyInserters.empty());
198   }
199 
200 }