001/*
002 * Copyright 2002-2018 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.servlet.view;
018
019import java.util.ArrayList;
020import java.util.Collection;
021import java.util.Collections;
022import java.util.LinkedHashSet;
023import java.util.List;
024import java.util.Locale;
025import java.util.Map;
026import java.util.Set;
027
028import javax.servlet.ServletContext;
029import javax.servlet.http.HttpServletRequest;
030import javax.servlet.http.HttpServletResponse;
031
032import org.springframework.beans.factory.BeanFactoryUtils;
033import org.springframework.beans.factory.InitializingBean;
034import org.springframework.core.Ordered;
035import org.springframework.core.annotation.AnnotationAwareOrderComparator;
036import org.springframework.http.MediaType;
037import org.springframework.lang.Nullable;
038import org.springframework.util.Assert;
039import org.springframework.util.CollectionUtils;
040import org.springframework.util.StringUtils;
041import org.springframework.web.HttpMediaTypeNotAcceptableException;
042import org.springframework.web.accept.ContentNegotiationManager;
043import org.springframework.web.accept.ContentNegotiationManagerFactoryBean;
044import org.springframework.web.context.request.RequestAttributes;
045import org.springframework.web.context.request.RequestContextHolder;
046import org.springframework.web.context.request.ServletRequestAttributes;
047import org.springframework.web.context.request.ServletWebRequest;
048import org.springframework.web.context.support.WebApplicationObjectSupport;
049import org.springframework.web.servlet.HandlerMapping;
050import org.springframework.web.servlet.SmartView;
051import org.springframework.web.servlet.View;
052import org.springframework.web.servlet.ViewResolver;
053
054/**
055 * Implementation of {@link ViewResolver} that resolves a view based on the request file name
056 * or {@code Accept} header.
057 *
058 * <p>The {@code ContentNegotiatingViewResolver} does not resolve views itself, but delegates to
059 * other {@link ViewResolver ViewResolvers}. By default, these other view resolvers are picked up automatically
060 * from the application context, though they can also be set explicitly by using the
061 * {@link #setViewResolvers viewResolvers} property. <strong>Note</strong> that in order for this
062 * view resolver to work properly, the {@link #setOrder order} property needs to be set to a higher
063 * precedence than the others (the default is {@link Ordered#HIGHEST_PRECEDENCE}).
064 *
065 * <p>This view resolver uses the requested {@linkplain MediaType media type} to select a suitable
066 * {@link View} for a request. The requested media type is determined through the configured
067 * {@link ContentNegotiationManager}. Once the requested media type has been determined, this resolver
068 * queries each delegate view resolver for a {@link View} and determines if the requested media type
069 * is {@linkplain MediaType#includes(MediaType) compatible} with the view's
070 * {@linkplain View#getContentType() content type}). The most compatible view is returned.
071 *
072 * <p>Additionally, this view resolver exposes the {@link #setDefaultViews(List) defaultViews} property,
073 * allowing you to override the views provided by the view resolvers. Note that these default views are
074 * offered as candidates, and still need have the content type requested (via file extension, parameter,
075 * or {@code Accept} header, described above).
076 *
077 * <p>For example, if the request path is {@code /view.html}, this view resolver will look for a view
078 * that has the {@code text/html} content type (based on the {@code html} file extension). A request
079 * for {@code /view} with a {@code text/html} request {@code Accept} header has the same result.
080 *
081 * @author Arjen Poutsma
082 * @author Juergen Hoeller
083 * @author Rossen Stoyanchev
084 * @since 3.0
085 * @see ViewResolver
086 * @see InternalResourceViewResolver
087 * @see BeanNameViewResolver
088 */
089public class ContentNegotiatingViewResolver extends WebApplicationObjectSupport
090                implements ViewResolver, Ordered, InitializingBean {
091
092        @Nullable
093        private ContentNegotiationManager contentNegotiationManager;
094
095        private final ContentNegotiationManagerFactoryBean cnmFactoryBean = new ContentNegotiationManagerFactoryBean();
096
097        private boolean useNotAcceptableStatusCode = false;
098
099        @Nullable
100        private List<View> defaultViews;
101
102        @Nullable
103        private List<ViewResolver> viewResolvers;
104
105        private int order = Ordered.HIGHEST_PRECEDENCE;
106
107
108        /**
109         * Set the {@link ContentNegotiationManager} to use to determine requested media types.
110         * <p>If not set, ContentNegotiationManager's default constructor will be used,
111         * applying a {@link org.springframework.web.accept.HeaderContentNegotiationStrategy}.
112         * @see ContentNegotiationManager#ContentNegotiationManager()
113         */
114        public void setContentNegotiationManager(@Nullable ContentNegotiationManager contentNegotiationManager) {
115                this.contentNegotiationManager = contentNegotiationManager;
116        }
117
118        /**
119         * Return the {@link ContentNegotiationManager} to use to determine requested media types.
120         * @since 4.1.9
121         */
122        @Nullable
123        public ContentNegotiationManager getContentNegotiationManager() {
124                return this.contentNegotiationManager;
125        }
126
127        /**
128         * Indicate whether a {@link HttpServletResponse#SC_NOT_ACCEPTABLE 406 Not Acceptable}
129         * status code should be returned if no suitable view can be found.
130         * <p>Default is {@code false}, meaning that this view resolver returns {@code null} for
131         * {@link #resolveViewName(String, Locale)} when an acceptable view cannot be found.
132         * This will allow for view resolvers chaining. When this property is set to {@code true},
133         * {@link #resolveViewName(String, Locale)} will respond with a view that sets the
134         * response status to {@code 406 Not Acceptable} instead.
135         */
136        public void setUseNotAcceptableStatusCode(boolean useNotAcceptableStatusCode) {
137                this.useNotAcceptableStatusCode = useNotAcceptableStatusCode;
138        }
139
140        /**
141         * Whether to return HTTP Status 406 if no suitable is found.
142         */
143        public boolean isUseNotAcceptableStatusCode() {
144                return this.useNotAcceptableStatusCode;
145        }
146
147        /**
148         * Set the default views to use when a more specific view can not be obtained
149         * from the {@link ViewResolver} chain.
150         */
151        public void setDefaultViews(List<View> defaultViews) {
152                this.defaultViews = defaultViews;
153        }
154
155        public List<View> getDefaultViews() {
156                return (this.defaultViews != null ? Collections.unmodifiableList(this.defaultViews) :
157                                Collections.emptyList());
158        }
159
160        /**
161         * Sets the view resolvers to be wrapped by this view resolver.
162         * <p>If this property is not set, view resolvers will be detected automatically.
163         */
164        public void setViewResolvers(List<ViewResolver> viewResolvers) {
165                this.viewResolvers = viewResolvers;
166        }
167
168        public List<ViewResolver> getViewResolvers() {
169                return (this.viewResolvers != null ? Collections.unmodifiableList(this.viewResolvers) :
170                                Collections.emptyList());
171        }
172
173        public void setOrder(int order) {
174                this.order = order;
175        }
176
177        @Override
178        public int getOrder() {
179                return this.order;
180        }
181
182
183        @Override
184        protected void initServletContext(ServletContext servletContext) {
185                Collection<ViewResolver> matchingBeans =
186                                BeanFactoryUtils.beansOfTypeIncludingAncestors(obtainApplicationContext(), ViewResolver.class).values();
187                if (this.viewResolvers == null) {
188                        this.viewResolvers = new ArrayList<>(matchingBeans.size());
189                        for (ViewResolver viewResolver : matchingBeans) {
190                                if (this != viewResolver) {
191                                        this.viewResolvers.add(viewResolver);
192                                }
193                        }
194                }
195                else {
196                        for (int i = 0; i < this.viewResolvers.size(); i++) {
197                                ViewResolver vr = this.viewResolvers.get(i);
198                                if (matchingBeans.contains(vr)) {
199                                        continue;
200                                }
201                                String name = vr.getClass().getName() + i;
202                                obtainApplicationContext().getAutowireCapableBeanFactory().initializeBean(vr, name);
203                        }
204
205                }
206                AnnotationAwareOrderComparator.sort(this.viewResolvers);
207                this.cnmFactoryBean.setServletContext(servletContext);
208        }
209
210        @Override
211        public void afterPropertiesSet() {
212                if (this.contentNegotiationManager == null) {
213                        this.contentNegotiationManager = this.cnmFactoryBean.build();
214                }
215                if (this.viewResolvers == null || this.viewResolvers.isEmpty()) {
216                        logger.warn("No ViewResolvers configured");
217                }
218        }
219
220
221        @Override
222        @Nullable
223        public View resolveViewName(String viewName, Locale locale) throws Exception {
224                RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
225                Assert.state(attrs instanceof ServletRequestAttributes, "No current ServletRequestAttributes");
226                List<MediaType> requestedMediaTypes = getMediaTypes(((ServletRequestAttributes) attrs).getRequest());
227                if (requestedMediaTypes != null) {
228                        List<View> candidateViews = getCandidateViews(viewName, locale, requestedMediaTypes);
229                        View bestView = getBestView(candidateViews, requestedMediaTypes, attrs);
230                        if (bestView != null) {
231                                return bestView;
232                        }
233                }
234
235                String mediaTypeInfo = logger.isDebugEnabled() && requestedMediaTypes != null ?
236                                " given " + requestedMediaTypes.toString() : "";
237
238                if (this.useNotAcceptableStatusCode) {
239                        if (logger.isDebugEnabled()) {
240                                logger.debug("Using 406 NOT_ACCEPTABLE" + mediaTypeInfo);
241                        }
242                        return NOT_ACCEPTABLE_VIEW;
243                }
244                else {
245                        logger.debug("View remains unresolved" + mediaTypeInfo);
246                        return null;
247                }
248        }
249
250        /**
251         * Determines the list of {@link MediaType} for the given {@link HttpServletRequest}.
252         * @param request the current servlet request
253         * @return the list of media types requested, if any
254         */
255        @Nullable
256        protected List<MediaType> getMediaTypes(HttpServletRequest request) {
257                Assert.state(this.contentNegotiationManager != null, "No ContentNegotiationManager set");
258                try {
259                        ServletWebRequest webRequest = new ServletWebRequest(request);
260                        List<MediaType> acceptableMediaTypes = this.contentNegotiationManager.resolveMediaTypes(webRequest);
261                        List<MediaType> producibleMediaTypes = getProducibleMediaTypes(request);
262                        Set<MediaType> compatibleMediaTypes = new LinkedHashSet<>();
263                        for (MediaType acceptable : acceptableMediaTypes) {
264                                for (MediaType producible : producibleMediaTypes) {
265                                        if (acceptable.isCompatibleWith(producible)) {
266                                                compatibleMediaTypes.add(getMostSpecificMediaType(acceptable, producible));
267                                        }
268                                }
269                        }
270                        List<MediaType> selectedMediaTypes = new ArrayList<>(compatibleMediaTypes);
271                        MediaType.sortBySpecificityAndQuality(selectedMediaTypes);
272                        return selectedMediaTypes;
273                }
274                catch (HttpMediaTypeNotAcceptableException ex) {
275                        if (logger.isDebugEnabled()) {
276                                logger.debug(ex.getMessage());
277                        }
278                        return null;
279                }
280        }
281
282        @SuppressWarnings("unchecked")
283        private List<MediaType> getProducibleMediaTypes(HttpServletRequest request) {
284                Set<MediaType> mediaTypes = (Set<MediaType>)
285                                request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
286                if (!CollectionUtils.isEmpty(mediaTypes)) {
287                        return new ArrayList<>(mediaTypes);
288                }
289                else {
290                        return Collections.singletonList(MediaType.ALL);
291                }
292        }
293
294        /**
295         * Return the more specific of the acceptable and the producible media types
296         * with the q-value of the former.
297         */
298        private MediaType getMostSpecificMediaType(MediaType acceptType, MediaType produceType) {
299                produceType = produceType.copyQualityValue(acceptType);
300                return (MediaType.SPECIFICITY_COMPARATOR.compare(acceptType, produceType) < 0 ? acceptType : produceType);
301        }
302
303        private List<View> getCandidateViews(String viewName, Locale locale, List<MediaType> requestedMediaTypes)
304                        throws Exception {
305
306                List<View> candidateViews = new ArrayList<>();
307                if (this.viewResolvers != null) {
308                        Assert.state(this.contentNegotiationManager != null, "No ContentNegotiationManager set");
309                        for (ViewResolver viewResolver : this.viewResolvers) {
310                                View view = viewResolver.resolveViewName(viewName, locale);
311                                if (view != null) {
312                                        candidateViews.add(view);
313                                }
314                                for (MediaType requestedMediaType : requestedMediaTypes) {
315                                        List<String> extensions = this.contentNegotiationManager.resolveFileExtensions(requestedMediaType);
316                                        for (String extension : extensions) {
317                                                String viewNameWithExtension = viewName + '.' + extension;
318                                                view = viewResolver.resolveViewName(viewNameWithExtension, locale);
319                                                if (view != null) {
320                                                        candidateViews.add(view);
321                                                }
322                                        }
323                                }
324                        }
325                }
326                if (!CollectionUtils.isEmpty(this.defaultViews)) {
327                        candidateViews.addAll(this.defaultViews);
328                }
329                return candidateViews;
330        }
331
332        @Nullable
333        private View getBestView(List<View> candidateViews, List<MediaType> requestedMediaTypes, RequestAttributes attrs) {
334                for (View candidateView : candidateViews) {
335                        if (candidateView instanceof SmartView) {
336                                SmartView smartView = (SmartView) candidateView;
337                                if (smartView.isRedirectView()) {
338                                        return candidateView;
339                                }
340                        }
341                }
342                for (MediaType mediaType : requestedMediaTypes) {
343                        for (View candidateView : candidateViews) {
344                                if (StringUtils.hasText(candidateView.getContentType())) {
345                                        MediaType candidateContentType = MediaType.parseMediaType(candidateView.getContentType());
346                                        if (mediaType.isCompatibleWith(candidateContentType)) {
347                                                if (logger.isDebugEnabled()) {
348                                                        logger.debug("Selected '" + mediaType + "' given " + requestedMediaTypes);
349                                                }
350                                                attrs.setAttribute(View.SELECTED_CONTENT_TYPE, mediaType, RequestAttributes.SCOPE_REQUEST);
351                                                return candidateView;
352                                        }
353                                }
354                        }
355                }
356                return null;
357        }
358
359
360        private static final View NOT_ACCEPTABLE_VIEW = new View() {
361
362                @Override
363                @Nullable
364                public String getContentType() {
365                        return null;
366                }
367
368                @Override
369                public void render(@Nullable Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) {
370                        response.setStatus(HttpServletResponse.SC_NOT_ACCEPTABLE);
371                }
372        };
373
374}