001/*
002 * Copyright 2002-2020 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.web.server.adapter;
018
019import java.util.Arrays;
020import java.util.HashSet;
021import java.util.Set;
022
023import org.apache.commons.logging.Log;
024import org.apache.commons.logging.LogFactory;
025import reactor.core.publisher.Mono;
026
027import org.springframework.context.ApplicationContext;
028import org.springframework.core.NestedExceptionUtils;
029import org.springframework.core.log.LogFormatUtils;
030import org.springframework.http.HttpHeaders;
031import org.springframework.http.HttpStatus;
032import org.springframework.http.codec.LoggingCodecSupport;
033import org.springframework.http.codec.ServerCodecConfigurer;
034import org.springframework.http.server.reactive.HttpHandler;
035import org.springframework.http.server.reactive.ServerHttpRequest;
036import org.springframework.http.server.reactive.ServerHttpResponse;
037import org.springframework.lang.Nullable;
038import org.springframework.util.Assert;
039import org.springframework.util.StringUtils;
040import org.springframework.web.server.ServerWebExchange;
041import org.springframework.web.server.WebHandler;
042import org.springframework.web.server.handler.WebHandlerDecorator;
043import org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver;
044import org.springframework.web.server.i18n.LocaleContextResolver;
045import org.springframework.web.server.session.DefaultWebSessionManager;
046import org.springframework.web.server.session.WebSessionManager;
047
048/**
049 * Default adapter of {@link WebHandler} to the {@link HttpHandler} contract.
050 *
051 * <p>By default creates and configures a {@link DefaultServerWebExchange} and
052 * then invokes the target {@code WebHandler}.
053 *
054 * @author Rossen Stoyanchev
055 * @author Sebastien Deleuze
056 * @since 5.0
057 */
058public class HttpWebHandlerAdapter extends WebHandlerDecorator implements HttpHandler {
059
060        /**
061         * Dedicated log category for disconnected client exceptions.
062         * <p>Servlet containers don't expose a client disconnected callback; see
063         * <a href="https://github.com/eclipse-ee4j/servlet-api/issues/44">eclipse-ee4j/servlet-api#44</a>.
064         * <p>To avoid filling logs with unnecessary stack traces, we make an
065         * effort to identify such network failures on a per-server basis, and then
066         * log under a separate log category a simple one-line message at DEBUG level
067         * or a full stack trace only at TRACE level.
068         */
069        private static final String DISCONNECTED_CLIENT_LOG_CATEGORY =
070                        "org.springframework.web.server.DisconnectedClient";
071
072         // Similar declaration exists in AbstractSockJsSession..
073        private static final Set<String> DISCONNECTED_CLIENT_EXCEPTIONS = new HashSet<>(
074                        Arrays.asList("AbortedException", "ClientAbortException", "EOFException", "EofException"));
075
076
077        private static final Log logger = LogFactory.getLog(HttpWebHandlerAdapter.class);
078
079        private static final Log lostClientLogger = LogFactory.getLog(DISCONNECTED_CLIENT_LOG_CATEGORY);
080
081
082        private WebSessionManager sessionManager = new DefaultWebSessionManager();
083
084        private ServerCodecConfigurer codecConfigurer = ServerCodecConfigurer.create();
085
086        private LocaleContextResolver localeContextResolver = new AcceptHeaderLocaleContextResolver();
087
088        @Nullable
089        private ForwardedHeaderTransformer forwardedHeaderTransformer;
090
091        @Nullable
092        private ApplicationContext applicationContext;
093
094        /** Whether to log potentially sensitive info (form data at DEBUG, headers at TRACE). */
095        private boolean enableLoggingRequestDetails = false;
096
097
098        public HttpWebHandlerAdapter(WebHandler delegate) {
099                super(delegate);
100        }
101
102
103        /**
104         * Configure a custom {@link WebSessionManager} to use for managing web
105         * sessions. The provided instance is set on each created
106         * {@link DefaultServerWebExchange}.
107         * <p>By default this is set to {@link DefaultWebSessionManager}.
108         * @param sessionManager the session manager to use
109         */
110        public void setSessionManager(WebSessionManager sessionManager) {
111                Assert.notNull(sessionManager, "WebSessionManager must not be null");
112                this.sessionManager = sessionManager;
113        }
114
115        /**
116         * Return the configured {@link WebSessionManager}.
117         */
118        public WebSessionManager getSessionManager() {
119                return this.sessionManager;
120        }
121
122        /**
123         * Configure a custom {@link ServerCodecConfigurer}. The provided instance is set on
124         * each created {@link DefaultServerWebExchange}.
125         * <p>By default this is set to {@link ServerCodecConfigurer#create()}.
126         * @param codecConfigurer the codec configurer to use
127         */
128        public void setCodecConfigurer(ServerCodecConfigurer codecConfigurer) {
129                Assert.notNull(codecConfigurer, "ServerCodecConfigurer is required");
130                this.codecConfigurer = codecConfigurer;
131
132                this.enableLoggingRequestDetails = false;
133                this.codecConfigurer.getReaders().stream()
134                                .filter(LoggingCodecSupport.class::isInstance)
135                                .forEach(reader -> {
136                                        if (((LoggingCodecSupport) reader).isEnableLoggingRequestDetails()) {
137                                                this.enableLoggingRequestDetails = true;
138                                        }
139                                });
140        }
141
142        /**
143         * Return the configured {@link ServerCodecConfigurer}.
144         */
145        public ServerCodecConfigurer getCodecConfigurer() {
146                return this.codecConfigurer;
147        }
148
149        /**
150         * Configure a custom {@link LocaleContextResolver}. The provided instance is set on
151         * each created {@link DefaultServerWebExchange}.
152         * <p>By default this is set to
153         * {@link org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver}.
154         * @param resolver the locale context resolver to use
155         */
156        public void setLocaleContextResolver(LocaleContextResolver resolver) {
157                Assert.notNull(resolver, "LocaleContextResolver is required");
158                this.localeContextResolver = resolver;
159        }
160
161        /**
162         * Return the configured {@link LocaleContextResolver}.
163         */
164        public LocaleContextResolver getLocaleContextResolver() {
165                return this.localeContextResolver;
166        }
167
168        /**
169         * Enable processing of forwarded headers, either extracting and removing,
170         * or remove only.
171         * <p>By default this is not set.
172         * @param transformer the transformer to use
173         * @since 5.1
174         */
175        public void setForwardedHeaderTransformer(ForwardedHeaderTransformer transformer) {
176                Assert.notNull(transformer, "ForwardedHeaderTransformer is required");
177                this.forwardedHeaderTransformer = transformer;
178        }
179
180        /**
181         * Return the configured {@link ForwardedHeaderTransformer}.
182         * @since 5.1
183         */
184        @Nullable
185        public ForwardedHeaderTransformer getForwardedHeaderTransformer() {
186                return this.forwardedHeaderTransformer;
187        }
188
189        /**
190         * Configure the {@code ApplicationContext} associated with the web application,
191         * if it was initialized with one via
192         * {@link org.springframework.web.server.adapter.WebHttpHandlerBuilder#applicationContext(ApplicationContext)}.
193         * @param applicationContext the context
194         * @since 5.0.3
195         */
196        public void setApplicationContext(ApplicationContext applicationContext) {
197                this.applicationContext = applicationContext;
198        }
199
200        /**
201         * Return the configured {@code ApplicationContext}, if any.
202         * @since 5.0.3
203         */
204        @Nullable
205        public ApplicationContext getApplicationContext() {
206                return this.applicationContext;
207        }
208
209        /**
210         * This method must be invoked after all properties have been set to
211         * complete initialization.
212         */
213        public void afterPropertiesSet() {
214                if (logger.isDebugEnabled()) {
215                        String value = this.enableLoggingRequestDetails ?
216                                        "shown which may lead to unsafe logging of potentially sensitive data" :
217                                        "masked to prevent unsafe logging of potentially sensitive data";
218                        logger.debug("enableLoggingRequestDetails='" + this.enableLoggingRequestDetails +
219                                        "': form data and headers will be " + value);
220                }
221        }
222
223
224        @Override
225        public Mono<Void> handle(ServerHttpRequest request, ServerHttpResponse response) {
226                if (this.forwardedHeaderTransformer != null) {
227                        request = this.forwardedHeaderTransformer.apply(request);
228                }
229                ServerWebExchange exchange = createExchange(request, response);
230
231                LogFormatUtils.traceDebug(logger, traceOn ->
232                                exchange.getLogPrefix() + formatRequest(exchange.getRequest()) +
233                                                (traceOn ? ", headers=" + formatHeaders(exchange.getRequest().getHeaders()) : ""));
234
235                return getDelegate().handle(exchange)
236                                .doOnSuccess(aVoid -> logResponse(exchange))
237                                .onErrorResume(ex -> handleUnresolvedError(exchange, ex))
238                                .then(Mono.defer(response::setComplete));
239        }
240
241        protected ServerWebExchange createExchange(ServerHttpRequest request, ServerHttpResponse response) {
242                return new DefaultServerWebExchange(request, response, this.sessionManager,
243                                getCodecConfigurer(), getLocaleContextResolver(), this.applicationContext);
244        }
245
246        /**
247         * Format the request for logging purposes including HTTP method and URL.
248         * <p>By default this prints the HTTP method, the URL path, and the query.
249         * @param request the request to format
250         * @return the String to display, never empty or {@code null}
251         */
252        protected String formatRequest(ServerHttpRequest request) {
253                String rawQuery = request.getURI().getRawQuery();
254                String query = StringUtils.hasText(rawQuery) ? "?" + rawQuery : "";
255                return "HTTP " + request.getMethod() + " \"" + request.getPath() + query + "\"";
256        }
257
258        private void logResponse(ServerWebExchange exchange) {
259                LogFormatUtils.traceDebug(logger, traceOn -> {
260                        HttpStatus status = exchange.getResponse().getStatusCode();
261                        return exchange.getLogPrefix() + "Completed " + (status != null ? status : "200 OK") +
262                                        (traceOn ? ", headers=" + formatHeaders(exchange.getResponse().getHeaders()) : "");
263                });
264        }
265
266        private String formatHeaders(HttpHeaders responseHeaders) {
267                return this.enableLoggingRequestDetails ?
268                                responseHeaders.toString() : responseHeaders.isEmpty() ? "{}" : "{masked}";
269        }
270
271        private Mono<Void> handleUnresolvedError(ServerWebExchange exchange, Throwable ex) {
272                ServerHttpRequest request = exchange.getRequest();
273                ServerHttpResponse response = exchange.getResponse();
274                String logPrefix = exchange.getLogPrefix();
275
276                // Sometimes a remote call error can look like a disconnected client.
277                // Try to set the response first before the "isDisconnectedClient" check.
278
279                if (response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR)) {
280                        logger.error(logPrefix + "500 Server Error for " + formatRequest(request), ex);
281                        return Mono.empty();
282                }
283                else if (isDisconnectedClientError(ex)) {
284                        if (lostClientLogger.isTraceEnabled()) {
285                                lostClientLogger.trace(logPrefix + "Client went away", ex);
286                        }
287                        else if (lostClientLogger.isDebugEnabled()) {
288                                lostClientLogger.debug(logPrefix + "Client went away: " + ex +
289                                                " (stacktrace at TRACE level for '" + DISCONNECTED_CLIENT_LOG_CATEGORY + "')");
290                        }
291                        return Mono.empty();
292                }
293                else {
294                        // After the response is committed, propagate errors to the server...
295                        logger.error(logPrefix + "Error [" + ex + "] for " + formatRequest(request) +
296                                        ", but ServerHttpResponse already committed (" + response.getStatusCode() + ")");
297                        return Mono.error(ex);
298                }
299        }
300
301        private boolean isDisconnectedClientError(Throwable ex) {
302                String message = NestedExceptionUtils.getMostSpecificCause(ex).getMessage();
303                if (message != null) {
304                        String text = message.toLowerCase();
305                        if (text.contains("broken pipe") || text.contains("connection reset by peer")) {
306                                return true;
307                        }
308                }
309                return DISCONNECTED_CLIENT_EXCEPTIONS.contains(ex.getClass().getSimpleName());
310        }
311
312}