org.springframework.web.servlet.view.ContentNegotiatingViewResolver Maven / Gradle / Ivy
/*
* Copyright 2002-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.servlet.view;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.BeanFactoryUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.MimeTypeUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.accept.ContentNegotiationManager;
import org.springframework.web.accept.ContentNegotiationManagerFactoryBean;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.context.support.WebApplicationObjectSupport;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.servlet.SmartView;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.ViewResolver;
/**
* Implementation of {@link ViewResolver} that resolves a view based on the request file name
* or {@code Accept} header.
*
* The {@code ContentNegotiatingViewResolver} does not resolve views itself, but delegates to
* other {@link ViewResolver ViewResolvers}. By default, these other view resolvers are picked up automatically
* from the application context, though they can also be set explicitly by using the
* {@link #setViewResolvers viewResolvers} property. Note that in order for this
* view resolver to work properly, the {@link #setOrder order} property needs to be set to a higher
* precedence than the others (the default is {@link Ordered#HIGHEST_PRECEDENCE}).
*
*
This view resolver uses the requested {@linkplain MediaType media type} to select a suitable
* {@link View} for a request. The requested media type is determined through the configured
* {@link ContentNegotiationManager}. Once the requested media type has been determined, this resolver
* queries each delegate view resolver for a {@link View} and determines if the requested media type
* is {@linkplain MediaType#includes(MediaType) compatible} with the view's
* {@linkplain View#getContentType() content type}). The most compatible view is returned.
*
*
Additionally, this view resolver exposes the {@link #setDefaultViews(List) defaultViews} property,
* allowing you to override the views provided by the view resolvers. Note that these default views are
* offered as candidates, and still need have the content type requested (via file extension, parameter,
* or {@code Accept} header, described above).
*
*
For example, if the request path is {@code /view.html}, this view resolver will look for a view
* that has the {@code text/html} content type (based on the {@code html} file extension). A request
* for {@code /view} with a {@code text/html} request {@code Accept} header has the same result.
*
* @author Arjen Poutsma
* @author Juergen Hoeller
* @author Rossen Stoyanchev
* @since 3.0
* @see ViewResolver
* @see InternalResourceViewResolver
* @see BeanNameViewResolver
*/
public class ContentNegotiatingViewResolver extends WebApplicationObjectSupport
implements ViewResolver, Ordered, InitializingBean {
@Nullable
private ContentNegotiationManager contentNegotiationManager;
private final ContentNegotiationManagerFactoryBean cnmFactoryBean = new ContentNegotiationManagerFactoryBean();
private boolean useNotAcceptableStatusCode = false;
@Nullable
private List defaultViews;
@Nullable
private List viewResolvers;
private int order = Ordered.HIGHEST_PRECEDENCE;
/**
* Set the {@link ContentNegotiationManager} to use to determine requested media types.
* If not set, ContentNegotiationManager's default constructor will be used,
* applying a {@link org.springframework.web.accept.HeaderContentNegotiationStrategy}.
* @see ContentNegotiationManager#ContentNegotiationManager()
*/
public void setContentNegotiationManager(@Nullable ContentNegotiationManager contentNegotiationManager) {
this.contentNegotiationManager = contentNegotiationManager;
}
/**
* Return the {@link ContentNegotiationManager} to use to determine requested media types.
* @since 4.1.9
*/
@Nullable
public ContentNegotiationManager getContentNegotiationManager() {
return this.contentNegotiationManager;
}
/**
* Indicate whether a {@link HttpServletResponse#SC_NOT_ACCEPTABLE 406 Not Acceptable}
* status code should be returned if no suitable view can be found.
*
Default is {@code false}, meaning that this view resolver returns {@code null} for
* {@link #resolveViewName(String, Locale)} when an acceptable view cannot be found.
* This will allow for view resolvers chaining. When this property is set to {@code true},
* {@link #resolveViewName(String, Locale)} will respond with a view that sets the
* response status to {@code 406 Not Acceptable} instead.
*/
public void setUseNotAcceptableStatusCode(boolean useNotAcceptableStatusCode) {
this.useNotAcceptableStatusCode = useNotAcceptableStatusCode;
}
/**
* Whether to return HTTP Status 406 if no suitable is found.
*/
public boolean isUseNotAcceptableStatusCode() {
return this.useNotAcceptableStatusCode;
}
/**
* Set the default views to use when a more specific view can not be obtained
* from the {@link ViewResolver} chain.
*/
public void setDefaultViews(List defaultViews) {
this.defaultViews = defaultViews;
}
public List getDefaultViews() {
return (this.defaultViews != null ? Collections.unmodifiableList(this.defaultViews) :
Collections.emptyList());
}
/**
* Sets the view resolvers to be wrapped by this view resolver.
* If this property is not set, view resolvers will be detected automatically.
*/
public void setViewResolvers(List viewResolvers) {
this.viewResolvers = viewResolvers;
}
public List getViewResolvers() {
return (this.viewResolvers != null ? Collections.unmodifiableList(this.viewResolvers) :
Collections.emptyList());
}
public void setOrder(int order) {
this.order = order;
}
@Override
public int getOrder() {
return this.order;
}
@Override
protected void initServletContext(ServletContext servletContext) {
Collection matchingBeans =
BeanFactoryUtils.beansOfTypeIncludingAncestors(obtainApplicationContext(), ViewResolver.class).values();
if (this.viewResolvers == null) {
this.viewResolvers = new ArrayList<>(matchingBeans.size());
for (ViewResolver viewResolver : matchingBeans) {
if (this != viewResolver) {
this.viewResolvers.add(viewResolver);
}
}
}
else {
for (int i = 0; i < this.viewResolvers.size(); i++) {
ViewResolver vr = this.viewResolvers.get(i);
if (matchingBeans.contains(vr)) {
continue;
}
String name = vr.getClass().getName() + i;
obtainApplicationContext().getAutowireCapableBeanFactory().initializeBean(vr, name);
}
}
AnnotationAwareOrderComparator.sort(this.viewResolvers);
this.cnmFactoryBean.setServletContext(servletContext);
}
@Override
public void afterPropertiesSet() {
if (this.contentNegotiationManager == null) {
this.contentNegotiationManager = this.cnmFactoryBean.build();
}
if (this.viewResolvers == null || this.viewResolvers.isEmpty()) {
logger.warn("No ViewResolvers configured");
}
}
@Override
@Nullable
public View resolveViewName(String viewName, Locale locale) throws Exception {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
Assert.state(attrs instanceof ServletRequestAttributes, "No current ServletRequestAttributes");
List requestedMediaTypes = getMediaTypes(((ServletRequestAttributes) attrs).getRequest());
if (requestedMediaTypes != null) {
List candidateViews = getCandidateViews(viewName, locale, requestedMediaTypes);
View bestView = getBestView(candidateViews, requestedMediaTypes, attrs);
if (bestView != null) {
return bestView;
}
}
String mediaTypeInfo = logger.isDebugEnabled() && requestedMediaTypes != null ?
" given " + requestedMediaTypes.toString() : "";
if (this.useNotAcceptableStatusCode) {
if (logger.isDebugEnabled()) {
logger.debug("Using 406 NOT_ACCEPTABLE" + mediaTypeInfo);
}
return NOT_ACCEPTABLE_VIEW;
}
else {
logger.debug("View remains unresolved" + mediaTypeInfo);
return null;
}
}
/**
* Determines the list of {@link MediaType} for the given {@link HttpServletRequest}.
* @param request the current servlet request
* @return the list of media types requested, if any
*/
@Nullable
protected List getMediaTypes(HttpServletRequest request) {
Assert.state(this.contentNegotiationManager != null, "No ContentNegotiationManager set");
try {
ServletWebRequest webRequest = new ServletWebRequest(request);
List acceptableMediaTypes = this.contentNegotiationManager.resolveMediaTypes(webRequest);
List producibleMediaTypes = getProducibleMediaTypes(request);
Set compatibleMediaTypes = new LinkedHashSet<>();
for (MediaType acceptable : acceptableMediaTypes) {
for (MediaType producible : producibleMediaTypes) {
if (acceptable.isCompatibleWith(producible)) {
compatibleMediaTypes.add(getMostSpecificMediaType(acceptable, producible));
}
}
}
List selectedMediaTypes = new ArrayList<>(compatibleMediaTypes);
MimeTypeUtils.sortBySpecificity(selectedMediaTypes);
return selectedMediaTypes;
}
catch (HttpMediaTypeNotAcceptableException ex) {
if (logger.isDebugEnabled()) {
logger.debug(ex.getMessage());
}
return null;
}
}
@SuppressWarnings("unchecked")
private List getProducibleMediaTypes(HttpServletRequest request) {
Set mediaTypes = (Set)
request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
if (!CollectionUtils.isEmpty(mediaTypes)) {
return new ArrayList<>(mediaTypes);
}
else {
return Collections.singletonList(MediaType.ALL);
}
}
/**
* Return the more specific of the acceptable and the producible media types
* with the q-value of the former.
*/
private MediaType getMostSpecificMediaType(MediaType acceptType, MediaType produceType) {
produceType = produceType.copyQualityValue(acceptType);
if (acceptType.isLessSpecific(produceType)) {
return produceType;
}
else {
return acceptType;
}
}
private List getCandidateViews(String viewName, Locale locale, List requestedMediaTypes)
throws Exception {
List candidateViews = new ArrayList<>();
if (this.viewResolvers != null) {
Assert.state(this.contentNegotiationManager != null, "No ContentNegotiationManager set");
for (ViewResolver viewResolver : this.viewResolvers) {
View view = viewResolver.resolveViewName(viewName, locale);
if (view != null) {
candidateViews.add(view);
}
for (MediaType requestedMediaType : requestedMediaTypes) {
List extensions = this.contentNegotiationManager.resolveFileExtensions(requestedMediaType);
for (String extension : extensions) {
String viewNameWithExtension = viewName + '.' + extension;
view = viewResolver.resolveViewName(viewNameWithExtension, locale);
if (view != null) {
candidateViews.add(view);
}
}
}
}
}
if (!CollectionUtils.isEmpty(this.defaultViews)) {
candidateViews.addAll(this.defaultViews);
}
return candidateViews;
}
@Nullable
private View getBestView(List candidateViews, List requestedMediaTypes, RequestAttributes attrs) {
for (View candidateView : candidateViews) {
if (candidateView instanceof SmartView smartView) {
if (smartView.isRedirectView()) {
return candidateView;
}
}
}
for (MediaType mediaType : requestedMediaTypes) {
for (View candidateView : candidateViews) {
if (StringUtils.hasText(candidateView.getContentType())) {
MediaType candidateContentType = MediaType.parseMediaType(candidateView.getContentType());
if (mediaType.isCompatibleWith(candidateContentType)) {
mediaType = mediaType.removeQualityValue();
if (logger.isDebugEnabled()) {
logger.debug("Selected '" + mediaType + "' given " + requestedMediaTypes);
}
attrs.setAttribute(View.SELECTED_CONTENT_TYPE, mediaType, RequestAttributes.SCOPE_REQUEST);
return candidateView;
}
}
}
}
return null;
}
private static final View NOT_ACCEPTABLE_VIEW = new View() {
@Override
@Nullable
public String getContentType() {
return null;
}
@Override
public void render(@Nullable Map model, HttpServletRequest request, HttpServletResponse response) {
response.setStatus(HttpServletResponse.SC_NOT_ACCEPTABLE);
}
};
}