From 4af99473ffa36a75fb4d6d521f736a403a4f904f Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Mon, 18 Apr 2016 17:40:33 -0400 Subject: [PATCH] Add CompositeContenTypeResolver and a builder This is the equivalent of the existing ContentNegotiationManager + ContentNegotiationManagerFactoryBean --- .../accept/CompositeContentTypeResolver.java | 105 +++++++++ .../CompositeContentTypeResolverBuilder.java | 200 ++++++++++++++++++ .../accept/FixedContentTypeResolver.java | 48 +++++ ...positeContentTypeResolverBuilderTests.java | 196 +++++++++++++++++ 4 files changed, 549 insertions(+) create mode 100644 spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/CompositeContentTypeResolver.java create mode 100644 spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/CompositeContentTypeResolverBuilder.java create mode 100644 spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/FixedContentTypeResolver.java create mode 100644 spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/CompositeContentTypeResolverBuilderTests.java diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/CompositeContentTypeResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/CompositeContentTypeResolver.java new file mode 100644 index 00000000000..71377ab377e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/CompositeContentTypeResolver.java @@ -0,0 +1,105 @@ +/* + * Copyright 2002-2016 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 + * + * http://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.reactive.accept; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@link ContentTypeResolver} that contains and delegates to a list of other + * resolvers. + * + *

Also an implementation of {@link MappingContentTypeResolver} that delegates + * to those resolvers from the list that are also of type + * {@code MappingContentTypeResolver}. + * + * @author Rossen Stoyanchev + */ +public class CompositeContentTypeResolver implements MappingContentTypeResolver { + + private final List resolvers = new ArrayList<>(); + + + public CompositeContentTypeResolver(List resolvers) { + Assert.notEmpty(resolvers, "At least one resolver is expected."); + this.resolvers.addAll(resolvers); + } + + + /** + * Return a read-only list of the configured resolvers. + */ + public List getResolvers() { + return Collections.unmodifiableList(this.resolvers); + } + + /** + * Return the first {@link ContentTypeResolver} of the given type. + * @param resolverType the resolver type + * @return the first matching resolver or {@code null}. + */ + @SuppressWarnings("unchecked") + public T findResolver(Class resolverType) { + for (ContentTypeResolver resolver : this.resolvers) { + if (resolverType.isInstance(resolver)) { + return (T) resolver; + } + } + return null; + } + + + @Override + public List resolveMediaTypes(ServerWebExchange exchange) throws HttpMediaTypeNotAcceptableException { + for (ContentTypeResolver resolver : this.resolvers) { + List mediaTypes = resolver.resolveMediaTypes(exchange); + if (mediaTypes.isEmpty() || (mediaTypes.size() == 1 && mediaTypes.contains(MediaType.ALL))) { + continue; + } + return mediaTypes; + } + return Collections.emptyList(); + } + + @Override + public Set getKeysFor(MediaType mediaType) { + Set result = new LinkedHashSet<>(); + for (ContentTypeResolver resolver : this.resolvers) { + if (resolver instanceof MappingContentTypeResolver) + result.addAll(((MappingContentTypeResolver) resolver).getKeysFor(mediaType)); + } + return result; + } + + @Override + public Set getKeys() { + Set result = new LinkedHashSet<>(); + for (ContentTypeResolver resolver : this.resolvers) { + if (resolver instanceof MappingContentTypeResolver) + result.addAll(((MappingContentTypeResolver) resolver).getKeys()); + } + return result; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/CompositeContentTypeResolverBuilder.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/CompositeContentTypeResolverBuilder.java new file mode 100644 index 00000000000..d136e323cc6 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/CompositeContentTypeResolverBuilder.java @@ -0,0 +1,200 @@ +/* + * Copyright 2002-2016 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 + * + * http://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.reactive.accept; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + + +/** + * Builder for {@link CompositeContentTypeResolver}. + * + * @author Rossen Stoyanchev + */ +public class CompositeContentTypeResolverBuilder { + + private boolean favorPathExtension = true; + + private boolean favorParameter = false; + + private boolean ignoreAcceptHeader = false; + + private Map mediaTypes = new HashMap<>(); + + private boolean ignoreUnknownPathExtensions = true; + + private Boolean useJaf; + + private String parameterName = "format"; + + private ContentTypeResolver contentTypeResolver; + + + /** + * Whether the path extension in the URL path should be used to determine + * the requested media type. + *

By default this is set to {@code true} in which case a request + * for {@code /hotels.pdf} will be interpreted as a request for + * {@code "application/pdf"} regardless of the 'Accept' header. + */ + public CompositeContentTypeResolverBuilder favorPathExtension(boolean favorPathExtension) { + this.favorPathExtension = favorPathExtension; + return this; + } + + /** + * Add a mapping from a key, extracted from a path extension or a query + * parameter, to a MediaType. This is required in order for the parameter + * strategy to work. Any extensions explicitly registered here are also + * whitelisted for the purpose of Reflected File Download attack detection + * (see Spring Framework reference documentation for more details on RFD + * attack protection). + *

The path extension strategy will also try to use JAF (if present) to + * resolve path extensions. To change this behavior see {@link #useJaf}. + * @param mediaTypes media type mappings + */ + public CompositeContentTypeResolverBuilder mediaTypes(Map mediaTypes) { + if (!CollectionUtils.isEmpty(mediaTypes)) { + for (Map.Entry entry : mediaTypes.entrySet()) { + String extension = entry.getKey().toLowerCase(Locale.ENGLISH); + this.mediaTypes.put(extension, entry.getValue()); + } + } + return this; + } + + /** + * Alternative to {@link #mediaTypes} to add a single mapping. + */ + public CompositeContentTypeResolverBuilder mediaType(String key, MediaType mediaType) { + this.mediaTypes.put(key, mediaType); + return this; + } + + /** + * Whether to ignore requests with path extension that cannot be resolved + * to any media type. Setting this to {@code false} will result in an + * {@link org.springframework.web.HttpMediaTypeNotAcceptableException} if + * there is no match. + *

By default this is set to {@code true}. + */ + public CompositeContentTypeResolverBuilder ignoreUnknownPathExtensions(boolean ignore) { + this.ignoreUnknownPathExtensions = ignore; + return this; + } + + /** + * When {@link #favorPathExtension favorPathExtension} is set, this + * property determines whether to allow use of JAF (Java Activation Framework) + * to resolve a path extension to a specific MediaType. + *

By default this is not set in which case + * {@code PathExtensionContentNegotiationStrategy} will use JAF if available. + */ + public CompositeContentTypeResolverBuilder useJaf(boolean useJaf) { + this.useJaf = useJaf; + return this; + } + + /** + * Whether a request parameter ("format" by default) should be used to + * determine the requested media type. For this option to work you must + * register {@link #mediaTypes media type mappings}. + *

By default this is set to {@code false}. + * @see #parameterName + */ + public CompositeContentTypeResolverBuilder favorParameter(boolean favorParameter) { + this.favorParameter = favorParameter; + return this; + } + + /** + * Set the query parameter name to use when {@link #favorParameter} is on. + *

The default parameter name is {@code "format"}. + */ + public CompositeContentTypeResolverBuilder parameterName(String parameterName) { + Assert.notNull(parameterName, "parameterName is required"); + this.parameterName = parameterName; + return this; + } + + /** + * Whether to disable checking the 'Accept' request header. + *

By default this value is set to {@code false}. + */ + public CompositeContentTypeResolverBuilder ignoreAcceptHeader(boolean ignoreAcceptHeader) { + this.ignoreAcceptHeader = ignoreAcceptHeader; + return this; + } + + /** + * Set the default content type to use when no content type is requested. + *

By default this is not set. + * @see #defaultContentTypeResolver + */ + public CompositeContentTypeResolverBuilder defaultContentType(MediaType contentType) { + this.contentTypeResolver = new FixedContentTypeResolver(contentType); + return this; + } + + /** + * Set a custom {@link ContentTypeResolver} to use to determine + * the content type to use when no content type is requested. + *

By default this is not set. + * @see #defaultContentType + */ + public CompositeContentTypeResolverBuilder defaultContentTypeResolver(ContentTypeResolver resolver) { + this.contentTypeResolver = resolver; + return this; + } + + + public CompositeContentTypeResolver build() { + List resolvers = new ArrayList<>(); + + if (this.favorPathExtension) { + PathExtensionContentTypeResolver resolver = new PathExtensionContentTypeResolver(this.mediaTypes); + resolver.setIgnoreUnknownExtensions(this.ignoreUnknownPathExtensions); + if (this.useJaf != null) { + resolver.setUseJaf(this.useJaf); + } + resolvers.add(resolver); + } + + if (this.favorParameter) { + ParameterContentTypeResolver resolver = new ParameterContentTypeResolver(this.mediaTypes); + resolver.setParameterName(this.parameterName); + resolvers.add(resolver); + } + + if (!this.ignoreAcceptHeader) { + resolvers.add(new HeaderContentTypeResolver()); + } + + if (this.contentTypeResolver != null) { + resolvers.add(this.contentTypeResolver); + } + + return new CompositeContentTypeResolver(resolvers); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/FixedContentTypeResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/FixedContentTypeResolver.java new file mode 100644 index 00000000000..a6c73bc0d11 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/FixedContentTypeResolver.java @@ -0,0 +1,48 @@ +/* + * Copyright 2002-2015 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 + * + * http://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.reactive.accept; + +import java.util.Collections; +import java.util.List; + +import org.springframework.http.MediaType; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@link ContentTypeResolver} that resolves to a fixed list of media types. + * + * @author Rossen Stoyanchev + */ +public class FixedContentTypeResolver implements ContentTypeResolver { + + private final List mediaTypes; + + + /** + * Create an instance with the given content type. + */ + public FixedContentTypeResolver(MediaType mediaTypes) { + this.mediaTypes = Collections.singletonList(mediaTypes); + } + + + @Override + public List resolveMediaTypes(ServerWebExchange exchange) { + return this.mediaTypes; + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/CompositeContentTypeResolverBuilderTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/CompositeContentTypeResolverBuilderTests.java new file mode 100644 index 00000000000..074dc914a7f --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/CompositeContentTypeResolverBuilderTests.java @@ -0,0 +1,196 @@ +/* + * Copyright 2002-2016 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 + * + * http://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.reactive.accept; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; + +import org.junit.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for {@link CompositeContentTypeResolverBuilder}. + * + * @author Rossen Stoyanchev + */ +public class CompositeContentTypeResolverBuilderTests { + + @Test + public void defaultSettings() throws Exception { + CompositeContentTypeResolver resolver = new CompositeContentTypeResolverBuilder().build(); + + ServerWebExchange exchange = createExchange("/flower.gif"); + + assertEquals("Should be able to resolve file extensions by default", + Collections.singletonList(MediaType.IMAGE_GIF), resolver.resolveMediaTypes(exchange)); + + exchange = createExchange("/flower.xyz"); + + assertEquals("Should ignore unknown extensions by default", + Collections.emptyList(), resolver.resolveMediaTypes(exchange)); + + exchange = createExchange("/flower"); + exchange.getRequest().getQueryParams().add("format", "gif"); + + assertEquals("Should not resolve request parameters by default", + Collections.emptyList(), resolver.resolveMediaTypes(exchange)); + + exchange = createExchange("/flower"); + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.IMAGE_GIF)); + + assertEquals("Should resolve Accept header by default", + Collections.singletonList(MediaType.IMAGE_GIF), resolver.resolveMediaTypes(exchange)); + } + + @Test + public void favorPath() throws Exception { + CompositeContentTypeResolver resolver = new CompositeContentTypeResolverBuilder() + .favorPathExtension(true) + .mediaType("foo", new MediaType("application", "foo")) + .mediaType("bar", new MediaType("application", "bar")) + .build(); + + ServerWebExchange exchange = createExchange("/flower.foo"); + assertEquals(Collections.singletonList(new MediaType("application", "foo")), + resolver.resolveMediaTypes(exchange)); + + exchange = createExchange("/flower.bar"); + assertEquals(Collections.singletonList(new MediaType("application", "bar")), + resolver.resolveMediaTypes(exchange)); + + exchange = createExchange("/flower.gif"); + assertEquals(Collections.singletonList(MediaType.IMAGE_GIF), resolver.resolveMediaTypes(exchange)); + } + + @Test + public void favorPathWithJafTurnedOff() throws Exception { + CompositeContentTypeResolver resolver = new CompositeContentTypeResolverBuilder() + .favorPathExtension(true) + .useJaf(false) + .build(); + + ServerWebExchange exchange = createExchange("/flower.foo"); + assertEquals(Collections.emptyList(), resolver.resolveMediaTypes(exchange)); + + exchange = createExchange("/flower.gif"); + assertEquals(Collections.emptyList(), resolver.resolveMediaTypes(exchange)); + } + + @Test(expected = HttpMediaTypeNotAcceptableException.class) // SPR-10170 + public void favorPathWithIgnoreUnknownPathExtensionTurnedOff() throws Exception { + CompositeContentTypeResolver resolver = new CompositeContentTypeResolverBuilder() + .favorPathExtension(true) + .ignoreUnknownPathExtensions(false) + .build(); + + ServerWebExchange exchange = createExchange("/flower.xyz"); + exchange.getRequest().getQueryParams().add("format", "json"); + + resolver.resolveMediaTypes(exchange); + } + + @Test + public void favorParameter() throws Exception { + CompositeContentTypeResolver resolver = new CompositeContentTypeResolverBuilder() + .favorParameter(true) + .mediaType("json", MediaType.APPLICATION_JSON) + .build(); + + ServerWebExchange exchange = createExchange("/flower"); + exchange.getRequest().getQueryParams().add("format", "json"); + + assertEquals(Collections.singletonList(MediaType.APPLICATION_JSON), + resolver.resolveMediaTypes(exchange)); + } + + @Test(expected = HttpMediaTypeNotAcceptableException.class) // SPR-10170 + public void favorParameterWithUnknownMediaType() throws Exception { + CompositeContentTypeResolver resolver = new CompositeContentTypeResolverBuilder() + .favorParameter(true) + .build(); + + ServerWebExchange exchange = createExchange("/flower"); + exchange.getRequest().getQueryParams().add("format", "xyz"); + + resolver.resolveMediaTypes(exchange); + } + + @Test + public void ignoreAcceptHeader() throws Exception { + CompositeContentTypeResolver resolver = new CompositeContentTypeResolverBuilder() + .ignoreAcceptHeader(true) + .build(); + + ServerWebExchange exchange = createExchange("/flower"); + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.IMAGE_GIF)); + + assertEquals(Collections.emptyList(), resolver.resolveMediaTypes(exchange)); + } + + @Test // SPR-10513 + public void setDefaultContentType() throws Exception { + CompositeContentTypeResolver resolver = new CompositeContentTypeResolverBuilder() + .defaultContentType(MediaType.APPLICATION_JSON) + .build(); + + ServerWebExchange exchange = createExchange("/"); + + assertEquals(Collections.singletonList(MediaType.APPLICATION_JSON), + resolver.resolveMediaTypes(exchange)); + + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.ALL)); + + assertEquals(Collections.singletonList(MediaType.APPLICATION_JSON), + resolver.resolveMediaTypes(exchange)); + } + + @Test // SPR-12286 + public void setDefaultContentTypeWithStrategy() throws Exception { + CompositeContentTypeResolver resolver = new CompositeContentTypeResolverBuilder() + .defaultContentTypeResolver(new FixedContentTypeResolver(MediaType.APPLICATION_JSON)) + .build(); + + ServerWebExchange exchange = createExchange("/"); + + assertEquals(Collections.singletonList(MediaType.APPLICATION_JSON), + resolver.resolveMediaTypes(exchange)); + + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.ALL)); + assertEquals(Collections.singletonList(MediaType.APPLICATION_JSON), + resolver.resolveMediaTypes(exchange)); + } + + + private ServerWebExchange createExchange(String path) throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI(path)); + WebSessionManager sessionManager = mock(WebSessionManager.class); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + +}