From 5d2fd75cf98324fc583453e2bde0c5a06987ab0f Mon Sep 17 00:00:00 2001 From: Arjen Poutsma Date: Wed, 6 Mar 2019 15:56:33 +0100 Subject: [PATCH] Introduce Servlet.fn This commit introduces Servlet.fn, a servlet-based version of WebFlux.fn, i.e. HandlerFunctions and RouterFunctions. This commit contains the web framework only, further commits provide tests and DispatcherServlet integration. See gh-21490 --- .../DefaultEntityResponseBuilder.java | 469 ++++++++ .../DefaultRenderingResponseBuilder.java | 195 +++ .../function/DefaultServerRequest.java | 402 +++++++ .../function/DefaultServerRequestBuilder.java | 397 +++++++ .../DefaultServerResponseBuilder.java | 417 +++++++ .../web/servlet/function/EntityResponse.java | 240 ++++ .../function/HandlerFilterFunction.java | 132 +++ .../web/servlet/function/HandlerFunction.java | 37 + .../function/PathResourceLookupFunction.java | 161 +++ .../servlet/function/RenderingResponse.java | 168 +++ .../servlet/function/RequestPredicate.java | 97 ++ .../servlet/function/RequestPredicates.java | 1044 +++++++++++++++++ .../function/ResourceHandlerFunction.java | 137 +++ .../web/servlet/function/RouterFunction.java | 117 ++ .../function/RouterFunctionBuilder.java | 251 ++++ .../web/servlet/function/RouterFunctions.java | 875 ++++++++++++++ .../web/servlet/function/ServerRequest.java | 416 +++++++ .../web/servlet/function/ServerResponse.java | 422 +++++++ .../web/servlet/function/ToStringVisitor.java | 167 +++ .../web/servlet/function/package-info.java | 9 + 20 files changed, 6153 insertions(+) create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultEntityResponseBuilder.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultRenderingResponseBuilder.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequestBuilder.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerResponseBuilder.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/EntityResponse.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/HandlerFilterFunction.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/HandlerFunction.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/PathResourceLookupFunction.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/RenderingResponse.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicate.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/ResourceHandlerFunction.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunction.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctionBuilder.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctions.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/ToStringVisitor.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/function/package-info.java diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultEntityResponseBuilder.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultEntityResponseBuilder.java new file mode 100644 index 0000000000..1011a0e2f5 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultEntityResponseBuilder.java @@ -0,0 +1,469 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.io.IOException; +import java.net.URI; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletionStage; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import javax.servlet.AsyncContext; +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; + +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.servlet.ModelAndView; + +/** + * Default {@link EntityResponse.Builder} implementation. + * + * @author Arjen Poutsma + * @since 5.2 + */ +class DefaultEntityResponseBuilder implements EntityResponse.Builder { + + private final T entity; + + private final BuilderFunction builderFunction; + + private int status = HttpStatus.OK.value(); + + private final HttpHeaders headers = new HttpHeaders(); + + private final MultiValueMap cookies = new LinkedMultiValueMap<>(); + + + private DefaultEntityResponseBuilder(T entity, BuilderFunction builderFunction) { + this.entity = entity; + this.builderFunction = builderFunction; + } + + @Override + public EntityResponse.Builder status(HttpStatus status) { + Assert.notNull(status, "HttpStatus must not be null"); + this.status = status.value(); + return this; + } + + @Override + public EntityResponse.Builder status(int status) { + this.status = status; + return this; + } + + @Override + public EntityResponse.Builder cookie(Cookie cookie) { + Assert.notNull(cookie, "Cookie must not be null"); + this.cookies.add(cookie.getName(), cookie); + return this; + } + + @Override + public EntityResponse.Builder cookies( + Consumer> cookiesConsumer) { + cookiesConsumer.accept(this.cookies); + return this; + } + + @Override + public EntityResponse.Builder header(String headerName, String... headerValues) { + for (String headerValue : headerValues) { + this.headers.add(headerName, headerValue); + } + return this; + } + + @Override + public EntityResponse.Builder headers(HttpHeaders headers) { + this.headers.putAll(headers); + return this; + } + + @Override + public EntityResponse.Builder allow(HttpMethod... allowedMethods) { + this.headers.setAllow(new LinkedHashSet<>(Arrays.asList(allowedMethods))); + return this; + } + + @Override + public EntityResponse.Builder allow(Set allowedMethods) { + this.headers.setAllow(allowedMethods); + return this; + } + + @Override + public EntityResponse.Builder contentLength(long contentLength) { + this.headers.setContentLength(contentLength); + return this; + } + + @Override + public EntityResponse.Builder contentType(MediaType contentType) { + this.headers.setContentType(contentType); + return this; + } + + @Override + public EntityResponse.Builder eTag(String etag) { + if (!etag.startsWith("\"") && !etag.startsWith("W/\"")) { + etag = "\"" + etag; + } + if (!etag.endsWith("\"")) { + etag = etag + "\""; + } + this.headers.setETag(etag); + return this; + } + + @Override + public EntityResponse.Builder lastModified(ZonedDateTime lastModified) { + this.headers.setLastModified(lastModified); + return this; + } + + @Override + public EntityResponse.Builder lastModified(Instant lastModified) { + this.headers.setLastModified(lastModified); + return this; + } + + @Override + public EntityResponse.Builder location(URI location) { + this.headers.setLocation(location); + return this; + } + + @Override + public EntityResponse.Builder cacheControl(CacheControl cacheControl) { + this.headers.setCacheControl(cacheControl); + return this; + } + + @Override + public EntityResponse.Builder varyBy(String... requestHeaders) { + this.headers.setVary(Arrays.asList(requestHeaders)); + return this; + } + + @Override + public EntityResponse build() { + return this.builderFunction.build(this.status, this.headers, this.cookies, this.entity); + } + + + /** + * Return a new {@link EntityResponse.Builder} from the given object. + */ + public static EntityResponse.Builder fromObject(T t) { + return new DefaultEntityResponseBuilder<>(t, DefaultEntityResponse::new); + } + + /** + * Return a new {@link EntityResponse.Builder} from the given completion stage. + */ + public static EntityResponse.Builder> fromCompletionStage( + CompletionStage completionStage) { + return new DefaultEntityResponseBuilder<>(completionStage, + CompletionStageEntityResponse::new); + } + + /** + * Return a new {@link EntityResponse.Builder} from the given Reactive Streams publisher. + */ + public static EntityResponse.Builder> fromPublisher(Publisher publisher) { + return new DefaultEntityResponseBuilder<>(publisher, PublisherEntityResponse::new); + } + + @SuppressWarnings("unchecked") + private static HttpMessageConverter cast(HttpMessageConverter messageConverter) { + return (HttpMessageConverter) messageConverter; + } + + + /** + * Defines contract for building {@link EntityResponse} instances. + */ + private interface BuilderFunction { + + EntityResponse build(int statusCode, HttpHeaders headers, + MultiValueMap cookies, T entity); + + } + + + /** + * Default {@link EntityResponse} implementation for synchronous bodies. + */ + private static class DefaultEntityResponse + extends DefaultServerResponseBuilder.AbstractServerResponse + implements EntityResponse { + + private final T entity; + + + public DefaultEntityResponse(int statusCode, HttpHeaders headers, + MultiValueMap cookies, T entity) { + + super(statusCode, headers, cookies); + this.entity = entity; + } + + @Override + public T entity() { + return this.entity; + } + + @Override + protected ModelAndView writeToInternal(HttpServletRequest servletRequest, + HttpServletResponse servletResponse, Context context) + throws ServletException, IOException { + + writeEntityWithMessageConverters(this.entity, servletRequest, servletResponse, context); + + return null; + } + + protected void writeEntityWithMessageConverters(Object entity, + HttpServletRequest request, HttpServletResponse response, + ServerResponse.Context context) + throws ServletException, IOException { + + ServletServerHttpResponse serverResponse = new ServletServerHttpResponse(response); + + MediaType contentType = getContentType(response); + Class entityType = entity.getClass(); + + HttpMessageConverter messageConverter = context.messageConverters().stream() + .filter(converter -> converter.canWrite(entityType, contentType)) + .findFirst() + .map(DefaultEntityResponseBuilder::cast) + .orElseThrow(() -> new HttpMediaTypeNotAcceptableException( + producibleMediaTypes(context.messageConverters(), entityType))); + + messageConverter.write(entity, contentType, serverResponse); + } + + @Nullable + private MediaType getContentType(HttpServletResponse response) { + try { + return MediaType.parseMediaType(response.getContentType()); + } + catch (InvalidMediaTypeException ex) { + return null; + } + } + + protected final void tryWriteEntityWithMessageConverters(Object entity, + HttpServletRequest request, HttpServletResponse response, + ServerResponse.Context context) { + try { + writeEntityWithMessageConverters(entity, request, response, context); + } + catch (IOException | ServletException ex) { + handleError(ex, request, response, context); + } + } + + private static List producibleMediaTypes( + List> messageConverters, + Class entityClass) { + + return messageConverters.stream() + .filter(messageConverter -> messageConverter.canWrite(entityClass, null)) + .flatMap(messageConverter -> messageConverter.getSupportedMediaTypes().stream()) + .collect(Collectors.toList()); + } + + } + + + /** + * {@link EntityResponse} implementation for asynchronous {@link CompletionStage} bodies. + */ + private static class CompletionStageEntityResponse + extends DefaultEntityResponse> { + + public CompletionStageEntityResponse(int statusCode, + HttpHeaders headers, + MultiValueMap cookies, CompletionStage entity) { + super(statusCode, headers, cookies, entity); + } + + @Override + protected ModelAndView writeToInternal(HttpServletRequest servletRequest, + HttpServletResponse servletResponse, Context context) { + + AsyncContext asyncContext = servletRequest.startAsync(servletRequest, servletResponse); + + entity().whenComplete((entity, throwable) -> { + try { + if (entity != null) { + tryWriteEntityWithMessageConverters(entity, + (HttpServletRequest) asyncContext.getRequest(), + (HttpServletResponse) asyncContext.getResponse(), + context); + } + else if (throwable != null) { + handleError(throwable, servletRequest, servletResponse, context); + } + } + finally { + asyncContext.complete(); + } + }); + return null; + } + } + + private static class PublisherEntityResponse extends DefaultEntityResponse> { + + public PublisherEntityResponse(int statusCode, HttpHeaders headers, + MultiValueMap cookies, Publisher entity) { + super(statusCode, headers, cookies, entity); + } + + @Override + protected ModelAndView writeToInternal(HttpServletRequest servletRequest, + HttpServletResponse servletResponse, Context context) { + + AsyncContext asyncContext = servletRequest.startAsync(servletRequest, + new NoContentLengthResponseWrapper(servletResponse)); + + entity().subscribe(new ProducingSubscriber(asyncContext, context)); + + return null; + } + + @SuppressWarnings("SubscriberImplementation") + private class ProducingSubscriber implements Subscriber { + + private final AsyncContext asyncContext; + + private final Context context; + + @Nullable + private Subscription subscription; + + public ProducingSubscriber(AsyncContext asyncContext, + Context context) { + this.asyncContext = asyncContext; + this.context = context; + } + + @Override + public void onSubscribe(Subscription s) { + Objects.requireNonNull(s); + + if (this.subscription == null) { + this.subscription = s; + this.subscription.request(Long.MAX_VALUE); + } + else { + s.cancel(); + } + } + + @Override + public void onNext(T element) { + Objects.requireNonNull(element); + HttpServletRequest servletRequest = + (HttpServletRequest) this.asyncContext.getRequest(); + HttpServletResponse servletResponse = + (HttpServletResponse) this.asyncContext.getResponse(); + + tryWriteEntityWithMessageConverters(element, + servletRequest, + servletResponse, + this.context); + } + + @Override + public void onError(Throwable t) { + Objects.requireNonNull(t); + + handleError(t, + (HttpServletRequest) this.asyncContext.getRequest(), + (HttpServletResponse) this.asyncContext.getResponse(), + this.context); + } + + @Override + public void onComplete() { + this.asyncContext.complete(); + } + + } + + private static class NoContentLengthResponseWrapper extends HttpServletResponseWrapper { + + public NoContentLengthResponseWrapper(HttpServletResponse response) { + super(response); + } + + @Override + public void addIntHeader(String name, int value) { + if (!HttpHeaders.CONTENT_LENGTH.equals(name)) { + super.addIntHeader(name, value); + } + } + + @Override + public void addHeader(String name, String value) { + if (!HttpHeaders.CONTENT_LENGTH.equals(name)) { + super.addHeader(name, value); + } + } + + @Override + public void setContentLength(int len) { + } + + @Override + public void setContentLengthLong(long len) { + } + } + + } + + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultRenderingResponseBuilder.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultRenderingResponseBuilder.java new file mode 100644 index 0000000000..7d77293ed0 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultRenderingResponseBuilder.java @@ -0,0 +1,195 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.core.Conventions; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.servlet.ModelAndView; + +/** + * Default {@link RenderingResponse.Builder} implementation. + * + * @author Arjen Poutsma + * @since 5.1 + */ +final class DefaultRenderingResponseBuilder implements RenderingResponse.Builder { + + private final String name; + + private int status = HttpStatus.OK.value(); + + private final HttpHeaders headers = new HttpHeaders(); + + private final MultiValueMap cookies = new LinkedMultiValueMap<>(); + + private final Map model = new LinkedHashMap<>(); + + + public DefaultRenderingResponseBuilder(RenderingResponse other) { + Assert.notNull(other, "RenderingResponse must not be null"); + this.name = other.name(); + this.status = (other instanceof DefaultRenderingResponse ? + ((DefaultRenderingResponse) other).statusCode : other.statusCode().value()); + this.headers.putAll(other.headers()); + this.model.putAll(other.model()); + } + + public DefaultRenderingResponseBuilder(String name) { + Assert.notNull(name, "Name must not be null"); + this.name = name; + } + + + @Override + public RenderingResponse.Builder status(HttpStatus status) { + Assert.notNull(status, "HttpStatus must not be null"); + this.status = status.value(); + return this; + } + + @Override + public RenderingResponse.Builder status(int status) { + this.status = status; + return this; + } + + @Override + public RenderingResponse.Builder cookie(Cookie cookie) { + Assert.notNull(cookie, "Cookie must not be null"); + this.cookies.add(cookie.getName(), cookie); + return this; + } + + @Override + public RenderingResponse.Builder cookies(Consumer> cookiesConsumer) { + cookiesConsumer.accept(this.cookies); + return this; + } + + @Override + public RenderingResponse.Builder modelAttribute(Object attribute) { + Assert.notNull(attribute, "Attribute must not be null"); + if (attribute instanceof Collection && ((Collection) attribute).isEmpty()) { + return this; + } + return modelAttribute(Conventions.getVariableName(attribute), attribute); + } + + @Override + public RenderingResponse.Builder modelAttribute(String name, @Nullable Object value) { + Assert.notNull(name, "Name must not be null"); + this.model.put(name, value); + return this; + } + + @Override + public RenderingResponse.Builder modelAttributes(Object... attributes) { + modelAttributes(Arrays.asList(attributes)); + return this; + } + + @Override + public RenderingResponse.Builder modelAttributes(Collection attributes) { + attributes.forEach(this::modelAttribute); + return this; + } + + @Override + public RenderingResponse.Builder modelAttributes(Map attributes) { + this.model.putAll(attributes); + return this; + } + + @Override + public RenderingResponse.Builder header(String headerName, String... headerValues) { + for (String headerValue : headerValues) { + this.headers.add(headerName, headerValue); + } + return this; + } + + @Override + public RenderingResponse.Builder headers(HttpHeaders headers) { + this.headers.putAll(headers); + return this; + } + + @Override + public RenderingResponse build() { + return new DefaultRenderingResponse(this.status, this.headers, this.cookies, this.name, this.model); + } + + + private static final class DefaultRenderingResponse extends DefaultServerResponseBuilder.AbstractServerResponse + implements RenderingResponse { + + private final String name; + + private final Map model; + + public DefaultRenderingResponse(int statusCode, HttpHeaders headers, + MultiValueMap cookies, String name, Map model) { + + super(statusCode, headers, cookies); + this.name = name; + this.model = Collections.unmodifiableMap(new LinkedHashMap<>(model)); + } + + @Override + public String name() { + return this.name; + } + + @Override + public Map model() { + return this.model; + } + + @Override + protected ModelAndView writeToInternal(HttpServletRequest request, + HttpServletResponse response, Context context) { + + HttpStatus status = HttpStatus.resolve(this.statusCode); + ModelAndView mav; + if (status != null) { + mav = new ModelAndView(this.name, status); + } + else { + mav = new ModelAndView(this.name); + } + mav.addAllObjects(this.model); + return mav; + } + + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java new file mode 100644 index 0000000000..179e4447ef --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java @@ -0,0 +1,402 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.Charset; +import java.security.Principal; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.stream.Collectors; +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +import org.jetbrains.annotations.NotNull; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRange; +import org.springframework.http.MediaType; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.ObjectUtils; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.util.UriBuilder; + +/** + * {@code ServerRequest} implementation based on a {@link HttpServletRequest}. + * + * @author Arjen Poutsma + * @since 5.2 + */ +class DefaultServerRequest implements ServerRequest { + + private final ServletServerHttpRequest serverHttpRequest; + + private final Headers headers; + + private final List> messageConverters; + + private final List allSupportedMediaTypes; + + private final MultiValueMap params; + + private final Map attributes; + + + public DefaultServerRequest(HttpServletRequest servletRequest, + List> messageConverters) { + this.serverHttpRequest = new ServletServerHttpRequest(servletRequest); + this.messageConverters = Collections.unmodifiableList(new ArrayList<>(messageConverters)); + this.allSupportedMediaTypes = allSupportedMediaTypes(messageConverters); + + this.headers = new DefaultRequestHeaders(this.serverHttpRequest.getHeaders()); + this.params = CollectionUtils.toMultiValueMap(new ServletParametersMap(servletRequest)); + this.attributes = new ServletAttributesMap(servletRequest); + } + + private static List allSupportedMediaTypes(List> messageConverters) { + return messageConverters.stream() + .flatMap(converter -> converter.getSupportedMediaTypes().stream()) + .sorted(MediaType.SPECIFICITY_COMPARATOR) + .collect(Collectors.toList()); + } + + @Override + public String methodName() { + return servletRequest().getMethod(); + } + + @Override + public URI uri() { + return this.serverHttpRequest.getURI(); + } + + @Override + public UriBuilder uriBuilder() { + return ServletUriComponentsBuilder.fromRequest(servletRequest()); + } + + @Override + public Headers headers() { + return this.headers; + } + + @Override + public MultiValueMap cookies() { + Cookie[] cookies = servletRequest().getCookies(); + if (cookies == null) { + cookies = new Cookie[0]; + } + MultiValueMap result = new LinkedMultiValueMap<>(cookies.length); + for (Cookie cookie : cookies) { + result.add(cookie.getName(), cookie); + } + return result; + } + + @Override + public HttpServletRequest servletRequest() { + return this.serverHttpRequest.getServletRequest(); + } + + @Override + public Optional remoteAddress() { + return Optional.of(this.serverHttpRequest.getRemoteAddress()); + } + + @Override + public List> messageConverters() { + return this.messageConverters; + } + + @Override + public T body(Class bodyType) throws IOException, ServletException { + return bodyInternal(bodyType, bodyType); + } + + @Override + public T body(ParameterizedTypeReference bodyType) throws IOException, ServletException { + Type type = bodyType.getType(); + Class contextClass = null; + if (type instanceof Class) { + contextClass = (Class) type; + } + return bodyInternal(type, contextClass); + } + + @SuppressWarnings("unchecked") + private T bodyInternal(Type type, @Nullable Class contextClass) + throws ServletException, IOException { + + MediaType contentType = + this.headers.contentType().orElse(MediaType.APPLICATION_OCTET_STREAM); + + for (HttpMessageConverter messageConverter : this.messageConverters) { + if (messageConverter instanceof GenericHttpMessageConverter) { + GenericHttpMessageConverter genericMessageConverter = + (GenericHttpMessageConverter) messageConverter; + if (genericMessageConverter.canRead(type, contextClass, contentType)) { + return genericMessageConverter.read(type, contextClass, this.serverHttpRequest); + } + } + else { + if (messageConverter.canRead(contextClass, contentType)) { + HttpMessageConverter theConverter = + (HttpMessageConverter) messageConverter; + Class clazz = (Class) contextClass; + return theConverter.read(clazz, this.serverHttpRequest); + } + } + } + throw new HttpMediaTypeNotSupportedException(contentType, this.allSupportedMediaTypes); + } + + @Override + public Optional attribute(String name) { + return Optional.ofNullable(servletRequest().getAttribute(name)); + } + + @Override + public Map attributes() { + return this.attributes; + } + + @Override + public MultiValueMap params() { + return this.params; + } + + @Override + public Map pathVariables() { + @SuppressWarnings("unchecked") + Map pathVariables = (Map) servletRequest() + .getAttribute(RouterFunctions.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + if (pathVariables != null) { + return pathVariables; + } else { + return Collections.emptyMap(); + } + } + + @Override + public HttpSession session() { + return servletRequest().getSession(true); + } + + @Override + public Optional principal() { + return Optional.ofNullable(this.serverHttpRequest.getPrincipal()); + } + + + static class DefaultRequestHeaders implements Headers { + + private final HttpHeaders delegate; + + + public DefaultRequestHeaders(HttpHeaders delegate) { + this.delegate = delegate; + } + + @Override + public List accept() { + return this.delegate.getAccept(); + } + + @Override + public List acceptCharset() { + return this.delegate.getAcceptCharset(); + } + + @Override + public List acceptLanguage() { + return this.delegate.getAcceptLanguage(); + } + + @Override + public OptionalLong contentLength() { + long value = this.delegate.getContentLength(); + return (value != -1 ? OptionalLong.of(value) : OptionalLong.empty()); + } + + @Override + public Optional contentType() { + return Optional.ofNullable(this.delegate.getContentType()); + } + + @Override + public InetSocketAddress host() { + return this.delegate.getHost(); + } + + @Override + public List range() { + return this.delegate.getRange(); + } + + @Override + public List header(String headerName) { + List headerValues = this.delegate.get(headerName); + return (headerValues != null ? headerValues : Collections.emptyList()); + } + + @Override + public HttpHeaders asHttpHeaders() { + return HttpHeaders.readOnlyHttpHeaders(this.delegate); + } + + @Override + public String toString() { + return this.delegate.toString(); + } + } + + private static class ServletParametersMap extends AbstractMap> { + + private final HttpServletRequest servletRequest; + + + private ServletParametersMap(HttpServletRequest servletRequest) { + this.servletRequest = servletRequest; + } + + @NotNull + @Override + public Set>> entrySet() { + return this.servletRequest.getParameterMap().entrySet().stream() + .map(entry -> { + List value = Arrays.asList(entry.getValue()); + return new SimpleImmutableEntry<>(entry.getKey(), value); + }) + .collect(Collectors.toSet()); + } + + @Override + public int size() { + return this.servletRequest.getParameterMap().size(); + } + + @Override + public List get(Object key) { + String name = (String) key; + String[] parameterValues = this.servletRequest.getParameterValues(name); + if (!ObjectUtils.isEmpty(parameterValues)) { + return Arrays.asList(parameterValues); + } + else { + return Collections.emptyList(); + } + } + + @Override + public List put(String key, List value) { + throw new UnsupportedOperationException(); + } + + @Override + public List remove(Object key) { + throw new UnsupportedOperationException(); + } + + @Override + public void clear() { + throw new UnsupportedOperationException(); + } + + } + + + private static class ServletAttributesMap extends AbstractMap { + + private final HttpServletRequest servletRequest; + + private ServletAttributesMap(HttpServletRequest servletRequest) { + this.servletRequest = servletRequest; + } + + @Override + public boolean containsKey(Object key) { + String name = (String) key; + return this.servletRequest.getAttribute(name) != null; + } + + @Override + public void clear() { + Enumeration attributeNames = this.servletRequest.getAttributeNames(); + while (attributeNames.hasMoreElements()) { + String name = attributeNames.nextElement(); + this.servletRequest.removeAttribute(name); + } + } + + @NotNull + @Override + public Set> entrySet() { + return Collections.list(this.servletRequest.getAttributeNames()).stream() + .map(name -> { + Object value = this.servletRequest.getAttribute(name); + return new SimpleImmutableEntry<>(name, value); + }) + .collect(Collectors.toSet()); + } + + @Override + public Object get(Object key) { + String name = (String) key; + return this.servletRequest.getAttribute(name); + } + + @Override + public Object put(String key, Object value) { + Object oldValue = this.servletRequest.getAttribute(key); + this.servletRequest.setAttribute(key, value); + return oldValue; + } + + @Override + public Object remove(Object key) { + String name = (String) key; + Object value = this.servletRequest.getAttribute(name); + this.servletRequest.removeAttribute(name); + return value; + } + + + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequestBuilder.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequestBuilder.java new file mode 100644 index 0000000000..196fd12f63 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequestBuilder.java @@ -0,0 +1,397 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Type; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import javax.servlet.ReadListener; +import javax.servlet.ServletException; +import javax.servlet.ServletInputStream; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +import org.jetbrains.annotations.NotNull; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.util.UriBuilder; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Default {@link ServerRequest.Builder} implementation. + * @author Arjen Poutsma + * @since 5.2 + */ +class DefaultServerRequestBuilder implements ServerRequest.Builder { + + private final List> messageConverters; + + private HttpServletRequest servletRequest; + + private String methodName; + + private URI uri; + + private final HttpHeaders headers = new HttpHeaders(); + + private final MultiValueMap cookies = new LinkedMultiValueMap<>(); + + private final Map attributes = new LinkedHashMap<>(); + + private byte[] body = new byte[0]; + + + public DefaultServerRequestBuilder(ServerRequest other) { + Assert.notNull(other, "ServerRequest must not be null"); + this.messageConverters = other.messageConverters(); + this.servletRequest = other.servletRequest(); + this.methodName = other.methodName(); + this.uri = other.uri(); + headers(headers -> headers.addAll(other.headers().asHttpHeaders())); + cookies(cookies -> cookies.addAll(other.cookies())); + attributes(attributes -> attributes.putAll(other.attributes())); + } + + @Override + public ServerRequest.Builder method(HttpMethod method) { + Assert.notNull(method, "HttpMethod must not be null"); + this.methodName = method.name(); + return this; + } + + @Override + public ServerRequest.Builder uri(URI uri) { + Assert.notNull(uri, "URI must not be null"); + this.uri = uri; + return this; + } + + @Override + public ServerRequest.Builder header(String headerName, String... headerValues) { + for (String headerValue : headerValues) { + this.headers.add(headerName, headerValue); + } + return this; + } + + @Override + public ServerRequest.Builder headers(Consumer headersConsumer) { + headersConsumer.accept(this.headers); + return this; + } + + @Override + public ServerRequest.Builder cookie(String name, String... values) { + for (String value : values) { + this.cookies.add(name, new Cookie(name, value)); + } + return this; + } + + @Override + public ServerRequest.Builder cookies(Consumer> cookiesConsumer) { + cookiesConsumer.accept(this.cookies); + return this; + } + + @Override + public ServerRequest.Builder body(byte[] body) { + this.body = body; + return this; + } + + @Override + public ServerRequest.Builder body(String body) { + return body(body.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public ServerRequest.Builder attribute(String name, Object value) { + Assert.notNull(name, "'name' must not be null"); + this.attributes.put(name, value); + return this; + } + + @Override + public ServerRequest.Builder attributes(Consumer> attributesConsumer) { + attributesConsumer.accept(this.attributes); + return this; + } + + @Override + public ServerRequest build() { + + return new BuiltServerRequest(this.servletRequest, + this.methodName, this.uri, this.headers, this.cookies, this.attributes, this.body, + this.messageConverters); + } + + + private static class BuiltServerRequest implements ServerRequest { + + private final String methodName; + + private final URI uri; + + private final HttpHeaders headers; + + private final HttpServletRequest servletRequest; + + private MultiValueMap cookies; + + private final Map attributes; + + private final byte[] body; + + private final List> messageConverters; + + public BuiltServerRequest(HttpServletRequest servletRequest, String methodName, URI uri, + HttpHeaders headers, MultiValueMap cookies, + Map attributes, byte[] body, + List> messageConverters) { + this.servletRequest = servletRequest; + this.methodName = methodName; + this.uri = uri; + this.headers = headers; + this.cookies = cookies; + this.attributes = attributes; + this.body = body; + this.messageConverters = messageConverters; + } + + @Override + public String methodName() { + return this.methodName; + } + + @Override + public URI uri() { + return this.uri; + } + + @Override + public UriBuilder uriBuilder() { + return UriComponentsBuilder.fromUri(this.uri); + } + + @Override + public Headers headers() { + return new DefaultServerRequest.DefaultRequestHeaders(this.headers); + } + + @Override + public MultiValueMap cookies() { + return this.cookies; + } + + @Override + public Optional remoteAddress() { + return Optional.empty(); + } + + @Override + public List> messageConverters() { + return this.messageConverters; + } + + @Override + public T body(Class bodyType) throws IOException, ServletException { + return bodyInternal(bodyType, bodyType); + } + + @Override + public T body(ParameterizedTypeReference bodyType) throws IOException, ServletException { + Type type = bodyType.getType(); + Class contextClass = null; + if (type instanceof Class) { + contextClass = (Class) type; + } + return bodyInternal(type, contextClass); + } + + @SuppressWarnings("unchecked") + private T bodyInternal(Type type, @Nullable Class contextClass) + throws ServletException, IOException { + + HttpInputMessage inputMessage = new BuiltInputMessage(); + MediaType contentType = headers().contentType().orElse(MediaType.APPLICATION_OCTET_STREAM); + + for (HttpMessageConverter messageConverter : this.messageConverters) { + if (messageConverter instanceof GenericHttpMessageConverter) { + GenericHttpMessageConverter genericMessageConverter = + (GenericHttpMessageConverter) messageConverter; + if (genericMessageConverter.canRead(type, contextClass, contentType)) { + return genericMessageConverter.read(type, contextClass, inputMessage); + } + } + else { + if (messageConverter.canRead(contextClass, contentType)) { + HttpMessageConverter theConverter = + (HttpMessageConverter) messageConverter; + Class clazz = (Class) contextClass; + return theConverter.read(clazz, inputMessage); + } + } + } + throw new HttpMediaTypeNotSupportedException(contentType, Collections.emptyList()); + } + + @Override + public Map attributes() { + return this.attributes; + } + + @Override + public MultiValueMap params() { + return new LinkedMultiValueMap<>(); + } + + @Override + public Map pathVariables() { + @SuppressWarnings("unchecked") + Map pathVariables = (Map) attributes() + .get(RouterFunctions.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + if (pathVariables != null) { + return pathVariables; + } else { + return Collections.emptyMap(); + } + } + + @Override + public HttpSession session() { + return this.servletRequest.getSession(); + } + + @Override + public Optional principal() { + return Optional.ofNullable(this.servletRequest.getUserPrincipal()); + } + + @Override + public HttpServletRequest servletRequest() { + return this.servletRequest; + } + + private class BuiltInputMessage implements HttpInputMessage { + + @Override + public InputStream getBody() throws IOException { + return new BodyInputStream(body); + } + + @Override + public HttpHeaders getHeaders() { + return headers; + } + } + } + + + private static class BodyInputStream extends ServletInputStream { + + private final InputStream delegate; + + public BodyInputStream(byte[] body) { + this.delegate = new ByteArrayInputStream(body); + } + + @Override + public boolean isFinished() { + return false; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + throw new UnsupportedOperationException(); + } + + @Override + public int read() throws IOException { + return this.delegate.read(); + } + + @Override + public int read(@NotNull byte[] b, int off, int len) throws IOException { + return this.delegate.read(b, off, len); + } + + @Override + public int read(@NotNull byte[] b) throws IOException { + return this.delegate.read(b); + } + + @Override + public long skip(long n) throws IOException { + return this.delegate.skip(n); + } + + @Override + public int available() throws IOException { + return this.delegate.available(); + } + + @Override + public void close() throws IOException { + this.delegate.close(); + } + + @Override + public synchronized void mark(int readlimit) { + this.delegate.mark(readlimit); + } + + @Override + public synchronized void reset() throws IOException { + this.delegate.reset(); + } + + @Override + public boolean markSupported() { + return this.delegate.markSupported(); + } + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerResponseBuilder.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerResponseBuilder.java new file mode 100644 index 0000000000..fae04122c7 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerResponseBuilder.java @@ -0,0 +1,417 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletionStage; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Predicate; +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.reactivestreams.Publisher; + +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.servlet.ModelAndView; + +/** + * Default {@link ServerResponse.BodyBuilder} implementation. + * @author Arjen Poutsma + * @since 5.2 + */ +class DefaultServerResponseBuilder implements ServerResponse.BodyBuilder { + + private final int statusCode; + + private final HttpHeaders headers = new HttpHeaders(); + + private final MultiValueMap cookies = new LinkedMultiValueMap<>(); + + + public DefaultServerResponseBuilder(ServerResponse other) { + Assert.notNull(other, "ServerResponse must not be null"); + this.statusCode = (other instanceof AbstractServerResponse ? + ((AbstractServerResponse) other).statusCode : other.statusCode().value()); + this.headers.addAll(other.headers()); + } + + public DefaultServerResponseBuilder(HttpStatus status) { + Assert.notNull(status, "HttpStatus must not be null"); + this.statusCode = status.value(); + } + + public DefaultServerResponseBuilder(int statusCode) { + this.statusCode = statusCode; + } + + @Override + public ServerResponse.BodyBuilder header(String headerName, String... headerValues) { + for (String headerValue : headerValues) { + this.headers.add(headerName, headerValue); + } + return this; + } + + @Override + public ServerResponse.BodyBuilder headers(Consumer headersConsumer) { + headersConsumer.accept(this.headers); + return this; + } + + @Override + public ServerResponse.BodyBuilder cookie(Cookie cookie) { + Assert.notNull(cookie, "Cookie must not be null"); + this.cookies.add(cookie.getName(), cookie); + return this; + } + + @Override + public ServerResponse.BodyBuilder cookies(Consumer> cookiesConsumer) { + cookiesConsumer.accept(this.cookies); + return this; + } + + @Override + public ServerResponse.BodyBuilder allow(HttpMethod... allowedMethods) { + this.headers.setAllow(new LinkedHashSet<>(Arrays.asList(allowedMethods))); + return this; + } + + @Override + public ServerResponse.BodyBuilder allow(Set allowedMethods) { + this.headers.setAllow(allowedMethods); + return this; + } + + @Override + public ServerResponse.BodyBuilder contentLength(long contentLength) { + this.headers.setContentLength(contentLength); + return this; + } + + @Override + public ServerResponse.BodyBuilder contentType(MediaType contentType) { + this.headers.setContentType(contentType); + return this; + } + + @Override + public ServerResponse.BodyBuilder eTag(String etag) { + if (!etag.startsWith("\"") && !etag.startsWith("W/\"")) { + etag = "\"" + etag; + } + if (!etag.endsWith("\"")) { + etag = etag + "\""; + } + this.headers.setETag(etag); + return this; + } + + @Override + public ServerResponse.BodyBuilder lastModified(ZonedDateTime lastModified) { + this.headers.setLastModified(lastModified); + return this; + } + + @Override + public ServerResponse.BodyBuilder lastModified(Instant lastModified) { + this.headers.setLastModified(lastModified); + return this; + } + + @Override + public ServerResponse.BodyBuilder location(URI location) { + this.headers.setLocation(location); + return this; + } + + @Override + public ServerResponse.BodyBuilder cacheControl(CacheControl cacheControl) { + this.headers.setCacheControl(cacheControl); + return this; + } + + @Override + public ServerResponse.BodyBuilder varyBy(String... requestHeaders) { + this.headers.setVary(Arrays.asList(requestHeaders)); + return this; + } + + @Override + public ServerResponse build() { + return build((request, response) -> null); + } + + @Override + public ServerResponse build( + BiFunction writeFunction) { + return new WriterFunctionResponse(this.statusCode, this.headers, this.cookies, writeFunction); + } + + @Override + public ServerResponse body(Object body) { + return DefaultEntityResponseBuilder.fromObject(body) + .headers(this.headers) + .status(this.statusCode) + .build(); + } + + @Override + public ServerResponse asyncBody(CompletionStage asyncBody) { + return DefaultEntityResponseBuilder.fromCompletionStage(asyncBody) + .headers(this.headers) + .status(this.statusCode) + .build(); + } + + @Override + public ServerResponse asyncBody(Publisher futureBody) { + return DefaultEntityResponseBuilder.fromPublisher(futureBody) + .headers(this.headers) + .status(this.statusCode) + .build(); + } + + @Override + public ServerResponse render(String name, Object... modelAttributes) { + return new DefaultRenderingResponseBuilder(name) + .headers(this.headers) + .status(this.statusCode) + .modelAttributes(modelAttributes) + .build(); + } + + @Override + public ServerResponse render(String name, Map model) { + return new DefaultRenderingResponseBuilder(name) + .headers(this.headers) + .status(this.statusCode) + .modelAttributes(model) + .build(); + } + + + /** + * Abstract base class for {@link ServerResponse} implementations. + */ + abstract static class AbstractServerResponse implements ServerResponse { + + private static final Set SAFE_METHODS = EnumSet.of(HttpMethod.GET, HttpMethod.HEAD); + + final int statusCode; + + private final HttpHeaders headers; + + private final MultiValueMap cookies; + + private final List> errorHandlers = new ArrayList<>(); + + + protected AbstractServerResponse( + int statusCode, HttpHeaders headers, MultiValueMap cookies) { + + this.statusCode = statusCode; + this.headers = HttpHeaders.readOnlyHttpHeaders(headers); + this.cookies = + CollectionUtils.unmodifiableMultiValueMap(new LinkedMultiValueMap<>(cookies)); + } + + protected void addErrorHandler(Predicate predicate, + BiFunction errorHandler) { + + Assert.notNull(predicate, "Predicate must not be null"); + Assert.notNull(errorHandler, "ErrorHandler must not be null"); + this.errorHandlers.add(new ErrorHandler<>(predicate, errorHandler)); + } + + + @Override + public final HttpStatus statusCode() { + return HttpStatus.valueOf(this.statusCode); + } + + @Override + public final HttpHeaders headers() { + return this.headers; + } + + @Override + public MultiValueMap cookies() { + return this.cookies; + } + + @Override + public ModelAndView writeTo(HttpServletRequest request, HttpServletResponse response, + Context context) throws ServletException, IOException { + + try { + writeStatusAndHeaders(response); + + long lastModified = headers().getLastModified(); + ServletWebRequest servletWebRequest = new ServletWebRequest(request, response); + HttpMethod httpMethod = HttpMethod.resolve(request.getMethod()); + if (SAFE_METHODS.contains(httpMethod) && + servletWebRequest.checkNotModified(headers().getETag(), lastModified)) { + return null; + } + else { + return writeToInternal(request, response, context); + } + } catch (Throwable t) { + return handleError(t, request, response, context); + } + } + + private void writeStatusAndHeaders(HttpServletResponse response) { + response.setStatus(this.statusCode); + writeHeaders(response); + writeCookies(response); + } + + private void writeHeaders(HttpServletResponse servletResponse) { + this.headers.forEach((headerName, headerValues) -> { + for (String headerValue : headerValues) { + servletResponse.addHeader(headerName, headerValue); + } + }); + // HttpServletResponse exposes some headers as properties: we should include those if not already present + if (servletResponse.getContentType() == null && this.headers.getContentType() != null) { + servletResponse.setContentType(this.headers.getContentType().toString()); + } + if (servletResponse.getCharacterEncoding() == null && + this.headers.getContentType() != null && + this.headers.getContentType().getCharset() != null) { + servletResponse + .setCharacterEncoding(this.headers.getContentType().getCharset().name()); + } + } + + private void writeCookies(HttpServletResponse servletResponse) { + this.cookies.values().stream() + .flatMap(Collection::stream) + .forEach(servletResponse::addCookie); + } + + @Nullable + protected abstract ModelAndView writeToInternal(HttpServletRequest request, + HttpServletResponse response, Context context) + throws ServletException, IOException; + + @Nullable + protected ModelAndView handleError(Throwable t, HttpServletRequest servletRequest, + HttpServletResponse servletResponse, Context context) { + + return this.errorHandlers.stream() + .filter(errorHandler -> errorHandler.test(t)) + .findFirst() + .map(errorHandler -> { + ServerRequest serverRequest = + (ServerRequest) servletRequest + .getAttribute(RouterFunctions.REQUEST_ATTRIBUTE); + ServerResponse serverResponse = errorHandler.handle(t, serverRequest); + try { + return serverResponse.writeTo(servletRequest, servletResponse, context); + } + catch (ServletException ex) { + throw new RuntimeException(ex); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + }) + .orElseThrow(() -> new RuntimeException(t)); + } + + + private static class ErrorHandler { + + private final Predicate predicate; + + private final BiFunction + responseProvider; + + public ErrorHandler(Predicate predicate, + BiFunction responseProvider) { + Assert.notNull(predicate, "Predicate must not be null"); + Assert.notNull(responseProvider, "ResponseProvider must not be null"); + this.predicate = predicate; + this.responseProvider = responseProvider; + } + + public boolean test(Throwable t) { + return this.predicate.test(t); + } + + public T handle(Throwable t, ServerRequest serverRequest) { + return this.responseProvider.apply(t, serverRequest); + } + } + + + } + + + private static class WriterFunctionResponse extends AbstractServerResponse { + + private final BiFunction writeFunction; + + + public WriterFunctionResponse(int statusCode, HttpHeaders headers, + MultiValueMap cookies, + BiFunction writeFunction) { + super(statusCode, headers, cookies); + Assert.notNull(writeFunction, "WriteFunction must not be null"); + this.writeFunction = writeFunction; + } + + @Override + protected ModelAndView writeToInternal(HttpServletRequest request, + HttpServletResponse response, Context context) { + return this.writeFunction.apply(request, response); + } + } + + + + + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/EntityResponse.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/EntityResponse.java new file mode 100644 index 0000000000..87339a5831 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/EntityResponse.java @@ -0,0 +1,240 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.net.URI; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.Set; +import java.util.concurrent.CompletionStage; +import java.util.function.Consumer; +import javax.servlet.http.Cookie; + +import org.reactivestreams.Publisher; + +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.util.MultiValueMap; + +/** + * Entity-specific subtype of {@link ServerResponse} that exposes entity data. + * + * @author Arjen Poutsma + * @since 5.2 + * @param the entity type + */ +public interface EntityResponse extends ServerResponse { + + /** + * Return the entity that makes up this response. + */ + T entity(); + + + // Static builder methods + + /** + * Create a builder with the given object. + * @param t the object that represents the body of the response + * @param the type of element contained in the publisher + * @return the created builder + */ + static Builder fromObject(T t) { + return DefaultEntityResponseBuilder.fromObject(t); + } + + /** + * Create a builder for an asynchronous body supplied by the given {@link CompletionStage}. + * @param completionStage the supplier of the response body + * @param the type of the elements contained in the publisher + * @return the created builder + */ + static Builder> fromCompletionStage(CompletionStage completionStage) { + return DefaultEntityResponseBuilder.fromCompletionStage(completionStage); + } + + /** + * Create a builder for an asynchronous body supplied by the given {@link Publisher}. + * @param publisher the supplier of the response body + * @param the type of the elements contained in the publisher + * @return the created builder + */ + static Builder> fromPublisher(Publisher publisher) { + return DefaultEntityResponseBuilder.fromPublisher(publisher); + } + + /** + * Defines a builder for {@code EntityResponse}. + */ + interface Builder { + + /** + * Add the given header value(s) under the given name. + * @param headerName the header name + * @param headerValues the header value(s) + * @return this builder + * @see HttpHeaders#add(String, String) + */ + Builder header(String headerName, String... headerValues); + + /** + * Copy the given headers into the entity's headers map. + * @param headers the existing HttpHeaders to copy from + * @return this builder + * @see HttpHeaders#add(String, String) + */ + Builder headers(HttpHeaders headers); + + /** + * Set the HTTP status. + * @param status the response status + * @return this builder + */ + Builder status(HttpStatus status); + + /** + * Set the HTTP status. + * @param status the response status + * @return this builder + */ + Builder status(int status); + + /** + * Add the given cookie to the response. + * @param cookie the cookie to add + * @return this builder + */ + Builder cookie(Cookie cookie); + + /** + * Manipulate this response's cookies with the given consumer. The + * cookies provided to the consumer are "live", so that the consumer can be used to + * {@linkplain MultiValueMap#set(Object, Object) overwrite} existing cookies, + * {@linkplain MultiValueMap#remove(Object) remove} cookies, or use any of the other + * {@link MultiValueMap} methods. + * @param cookiesConsumer a function that consumes the cookies + * @return this builder + */ + Builder cookies(Consumer> cookiesConsumer); + + /** + * Set the set of allowed {@link HttpMethod HTTP methods}, as specified + * by the {@code Allow} header. + * @param allowedMethods the allowed methods + * @return this builder + * @see HttpHeaders#setAllow(Set) + */ + Builder allow(HttpMethod... allowedMethods); + + /** + * Set the set of allowed {@link HttpMethod HTTP methods}, as specified + * by the {@code Allow} header. + * @param allowedMethods the allowed methods + * @return this builder + * @see HttpHeaders#setAllow(Set) + */ + Builder allow(Set allowedMethods); + + /** + * Set the entity tag of the body, as specified by the {@code ETag} header. + * @param etag the new entity tag + * @return this builder + * @see HttpHeaders#setETag(String) + */ + Builder eTag(String etag); + + /** + * Set the time the resource was last changed, as specified by the + * {@code Last-Modified} header. + *

The date should be specified as the number of milliseconds since + * January 1, 1970 GMT. + * @param lastModified the last modified date + * @return this builder + * @see HttpHeaders#setLastModified(long) + */ + Builder lastModified(ZonedDateTime lastModified); + + /** + * Set the time the resource was last changed, as specified by the + * {@code Last-Modified} header. + *

The date should be specified as the number of milliseconds since + * January 1, 1970 GMT. + * @param lastModified the last modified date + * @return this builder + * @since 5.1.4 + * @see HttpHeaders#setLastModified(long) + */ + Builder lastModified(Instant lastModified); + /** + * Set the location of a resource, as specified by the {@code Location} header. + * @param location the location + * @return this builder + * @see HttpHeaders#setLocation(URI) + */ + Builder location(URI location); + + /** + * Set the caching directives for the resource, as specified by the HTTP 1.1 + * {@code Cache-Control} header. + *

A {@code CacheControl} instance can be built like + * {@code CacheControl.maxAge(3600).cachePublic().noTransform()}. + * @param cacheControl a builder for cache-related HTTP response headers + * @return this builder + * @see RFC-7234 Section 5.2 + */ + Builder cacheControl(CacheControl cacheControl); + + /** + * Configure one or more request header names (e.g. "Accept-Language") to + * add to the "Vary" response header to inform clients that the response is + * subject to content negotiation and variances based on the value of the + * given request headers. The configured request header names are added only + * if not already present in the response "Vary" header. + * @param requestHeaders request header names + * @return this builder + */ + Builder varyBy(String... requestHeaders); + + /** + * Set the length of the body in bytes, as specified by the + * {@code Content-Length} header. + * @param contentLength the content length + * @return this builder + * @see HttpHeaders#setContentLength(long) + */ + Builder contentLength(long contentLength); + + /** + * Set the {@linkplain MediaType media type} of the body, as specified by the + * {@code Content-Type} header. + * @param contentType the content type + * @return this builder + * @see HttpHeaders#setContentType(MediaType) + */ + Builder contentType(MediaType contentType); + + /** + * Build the response. + * @return the built response + */ + EntityResponse build(); + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/HandlerFilterFunction.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/HandlerFilterFunction.java new file mode 100644 index 0000000000..a3310567f6 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/HandlerFilterFunction.java @@ -0,0 +1,132 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Predicate; + +import org.springframework.util.Assert; + +/** + * Represents a function that filters a {@linkplain HandlerFunction handler function}. + * + * @author Arjen Poutsma + * @since 5.2 + * @param the type of the {@linkplain HandlerFunction handler function} to filter + * @param the type of the response of the function + * @see RouterFunction#filter(HandlerFilterFunction) + */ +@FunctionalInterface +public interface HandlerFilterFunction { + + /** + * Apply this filter to the given handler function. The given + * {@linkplain HandlerFunction handler function} represents the next entity in the chain, + * and can be {@linkplain HandlerFunction#handle(ServerRequest) invoked} in order to + * proceed to this entity, or not invoked to block the chain. + * @param request the request + * @param next the next handler or filter function in the chain + * @return the filtered response + */ + R filter(ServerRequest request, HandlerFunction next) throws Exception; + + /** + * Return a composed filter function that first applies this filter, and then applies the + * {@code after} filter. + * @param after the filter to apply after this filter is applied + * @return a composed filter that first applies this function and then applies the + * {@code after} function + */ + default HandlerFilterFunction andThen(HandlerFilterFunction after) { + Assert.notNull(after, "HandlerFilterFunction must not be null"); + return (request, next) -> { + HandlerFunction nextHandler = handlerRequest -> after.filter(handlerRequest, next); + return filter(request, nextHandler); + }; + } + + /** + * Apply this filter to the given handler function, resulting in a filtered handler function. + * @param handler the handler function to filter + * @return the filtered handler function + */ + default HandlerFunction apply(HandlerFunction handler) { + Assert.notNull(handler, "HandlerFunction must not be null"); + return request -> this.filter(request, handler); + } + + /** + * Adapt the given request processor function to a filter function that only operates + * on the {@code ServerRequest}. + * @param requestProcessor the request processor + * @return the filter adaptation of the request processor + */ + static HandlerFilterFunction + ofRequestProcessor(Function requestProcessor) { + + Assert.notNull(requestProcessor, "Function must not be null"); + return (request, next) -> next.handle(requestProcessor.apply(request)); + } + + /** + * Adapt the given response processor function to a filter function that only operates + * on the {@code ServerResponse}. + * @param responseProcessor the response processor + * @return the filter adaptation of the request processor + */ + static HandlerFilterFunction + ofResponseProcessor(BiFunction responseProcessor) { + + Assert.notNull(responseProcessor, "Function must not be null"); + return (request, next) -> responseProcessor.apply(request, next.handle(request)); + } + + /** + * Adapt the given predicate and response provider function to a filter function that returns + * a {@code ServerResponse} on a given exception. + * @param predicate the predicate to match an exception + * @param errorHandler the response provider + * @return the filter adaption of the error handler + */ + static HandlerFilterFunction + ofErrorHandler(Predicate predicate, BiFunction errorHandler) { + + Assert.notNull(predicate, "Predicate must not be null"); + Assert.notNull(errorHandler, "ErrorHandler must not be null"); + + return (request, next) -> { + try { + T t = next.handle(request); + if (t instanceof DefaultServerResponseBuilder.AbstractServerResponse) { + ((DefaultServerResponseBuilder.AbstractServerResponse) t) + .addErrorHandler(predicate, errorHandler); + } + return t; + } + catch (Throwable throwable) { + if (predicate.test(throwable)) { + return errorHandler.apply(throwable, request); + } + else { + throw throwable; + } + } + }; + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/HandlerFunction.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/HandlerFunction.java new file mode 100644 index 0000000000..f2515668d7 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/HandlerFunction.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +/** + * Represents a function that handles a {@linkplain ServerRequest request}. + * + * @author Arjen Poutsma + * @since 5.2 + * @param the type of the response of the function + * @see RouterFunction + */ +@FunctionalInterface +public interface HandlerFunction { + + /** + * Handle the given request. + * @param request the request to handle + * @return the response + */ + T handle(ServerRequest request) throws Exception; + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/PathResourceLookupFunction.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/PathResourceLookupFunction.java new file mode 100644 index 0000000000..b412f8ebd8 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/PathResourceLookupFunction.java @@ -0,0 +1,161 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.function.Function; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.server.PathContainer; +import org.springframework.util.Assert; +import org.springframework.util.ResourceUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * Lookup function used by {@link RouterFunctions#resources(String, Resource)}. + * + * @author Arjen Poutsma + * @since 5.2 + */ +class PathResourceLookupFunction implements Function> { + + private static final PathPatternParser PATTERN_PARSER = new PathPatternParser(); + + private final PathPattern pattern; + + private final Resource location; + + + public PathResourceLookupFunction(String pattern, Resource location) { + Assert.hasLength(pattern, "'pattern' must not be empty"); + Assert.notNull(location, "'location' must not be null"); + this.pattern = PATTERN_PARSER.parse(pattern); + this.location = location; + } + + + @Override + public Optional apply(ServerRequest request) { + PathContainer pathContainer = request.pathContainer(); + if (!this.pattern.matches(pathContainer)) { + return Optional.empty(); + } + + pathContainer = this.pattern.extractPathWithinPattern(pathContainer); + String path = processPath(pathContainer.value()); + if (path.contains("%")) { + path = StringUtils.uriDecode(path, StandardCharsets.UTF_8); + } + if (!StringUtils.hasLength(path) || isInvalidPath(path)) { + return Optional.empty(); + } + + try { + Resource resource = this.location.createRelative(path); + if (resource.exists() && resource.isReadable() && isResourceUnderLocation(resource)) { + return Optional.of(resource); + } + else { + return Optional.empty(); + } + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private String processPath(String path) { + boolean slash = false; + for (int i = 0; i < path.length(); i++) { + if (path.charAt(i) == '/') { + slash = true; + } + else if (path.charAt(i) > ' ' && path.charAt(i) != 127) { + if (i == 0 || (i == 1 && slash)) { + return path; + } + path = slash ? "/" + path.substring(i) : path.substring(i); + return path; + } + } + return (slash ? "/" : ""); + } + + private boolean isInvalidPath(String path) { + if (path.contains("WEB-INF") || path.contains("META-INF")) { + return true; + } + if (path.contains(":/")) { + String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path); + if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) { + return true; + } + } + if (path.contains("..") && StringUtils.cleanPath(path).contains("../")) { + return true; + } + return false; + } + + private boolean isResourceUnderLocation(Resource resource) throws IOException { + if (resource.getClass() != this.location.getClass()) { + return false; + } + + String resourcePath; + String locationPath; + + if (resource instanceof UrlResource) { + resourcePath = resource.getURL().toExternalForm(); + locationPath = StringUtils.cleanPath(this.location.getURL().toString()); + } + else if (resource instanceof ClassPathResource) { + resourcePath = ((ClassPathResource) resource).getPath(); + locationPath = StringUtils.cleanPath(((ClassPathResource) this.location).getPath()); + } + else { + resourcePath = resource.getURL().getPath(); + locationPath = StringUtils.cleanPath(this.location.getURL().getPath()); + } + + if (locationPath.equals(resourcePath)) { + return true; + } + locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : locationPath + "/"); + if (!resourcePath.startsWith(locationPath)) { + return false; + } + if (resourcePath.contains("%") && StringUtils.uriDecode(resourcePath, StandardCharsets.UTF_8).contains("../")) { + return false; + } + return true; + } + + + @Override + public String toString() { + return this.pattern + " -> " + this.location; + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RenderingResponse.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RenderingResponse.java new file mode 100644 index 0000000000..3aadc12d45 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RenderingResponse.java @@ -0,0 +1,168 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.util.Collection; +import java.util.Map; +import java.util.function.Consumer; +import javax.servlet.http.Cookie; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.lang.Nullable; +import org.springframework.util.MultiValueMap; + +/** + * Rendering-specific subtype of {@link ServerResponse} that exposes model and template data. + * + * @author Arjen Poutsma + * @since 5.2 + */ +public interface RenderingResponse extends ServerResponse { + + /** + * Return the name of the template to be rendered. + */ + String name(); + + /** + * Return the unmodifiable model map. + */ + Map model(); + + + // Builder + + /** + * Create a builder with the template name, status code, headers and model of the given response. + * @param other the response to copy the values from + * @return the created builder + */ + static Builder from(RenderingResponse other) { + return new DefaultRenderingResponseBuilder(other); + } + + /** + * Create a builder with the given template name. + * @param name the name of the template to render + * @return the created builder + */ + static Builder create(String name) { + return new DefaultRenderingResponseBuilder(name); + } + + + /** + * Defines a builder for {@code RenderingResponse}. + */ + interface Builder { + + /** + * Add the supplied attribute to the model using a + * {@linkplain org.springframework.core.Conventions#getVariableName generated name}. + *

Note: Empty {@link Collection Collections} are not added to + * the model when using this method because we cannot correctly determine + * the true convention name. View code should check for {@code null} rather + * than for empty collections. + * @param attribute the model attribute value (never {@code null}) + */ + Builder modelAttribute(Object attribute); + + /** + * Add the supplied attribute value under the supplied name. + * @param name the name of the model attribute (never {@code null}) + * @param value the model attribute value (can be {@code null}) + */ + Builder modelAttribute(String name, @Nullable Object value); + + /** + * Copy all attributes in the supplied array into the model, + * using attribute name generation for each element. + * @see #modelAttribute(Object) + */ + Builder modelAttributes(Object... attributes); + + /** + * Copy all attributes in the supplied {@code Collection} into the model, + * using attribute name generation for each element. + * @see #modelAttribute(Object) + */ + Builder modelAttributes(Collection attributes); + + /** + * Copy all attributes in the supplied {@code Map} into the model. + * @see #modelAttribute(String, Object) + */ + Builder modelAttributes(Map attributes); + + /** + * Add the given header value(s) under the given name. + * @param headerName the header name + * @param headerValues the header value(s) + * @return this builder + * @see HttpHeaders#add(String, String) + */ + Builder header(String headerName, String... headerValues); + + /** + * Copy the given headers into the entity's headers map. + * @param headers the existing HttpHeaders to copy from + * @return this builder + * @see HttpHeaders#add(String, String) + */ + Builder headers(HttpHeaders headers); + + /** + * Set the HTTP status. + * @param status the response status + * @return this builder + */ + Builder status(HttpStatus status); + + /** + * Set the HTTP status. + * @param status the response status + * @return this builder + */ + Builder status(int status); + + /** + * Add the given cookie to the response. + * @param cookie the cookie to add + * @return this builder + */ + Builder cookie(Cookie cookie); + + /** + * Manipulate this response's cookies with the given consumer. The + * cookies provided to the consumer are "live", so that the consumer can be used to + * {@linkplain MultiValueMap#set(Object, Object) overwrite} existing cookies, + * {@linkplain MultiValueMap#remove(Object) remove} cookies, or use any of the other + * {@link MultiValueMap} methods. + * @param cookiesConsumer a function that consumes the cookies + * @return this builder + */ + Builder cookies(Consumer> cookiesConsumer); + + /** + * Build the response. + * @return the built response + */ + RenderingResponse build(); + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicate.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicate.java new file mode 100644 index 0000000000..ada345c973 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicate.java @@ -0,0 +1,97 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.util.Optional; + +/** + * Represents a function that evaluates on a given {@link ServerRequest}. + * Instances of this function that evaluate on common request properties + * can be found in {@link RequestPredicates}. + * + * @author Arjen Poutsma + * @since 5.2 + * @see RequestPredicates + * @see RouterFunctions#route(RequestPredicate, HandlerFunction) + * @see RouterFunctions#nest(RequestPredicate, RouterFunction) + */ +@FunctionalInterface +public interface RequestPredicate { + + /** + * Evaluate this predicate on the given request. + * @param request the request to match against + * @return {@code true} if the request matches the predicate; {@code false} otherwise + */ + boolean test(ServerRequest request); + + /** + * Return a composed request predicate that tests against both this predicate AND + * the {@code other} predicate. When evaluating the composed predicate, if this + * predicate is {@code false}, then the {@code other} predicate is not evaluated. + * @param other a predicate that will be logically-ANDed with this predicate + * @return a predicate composed of this predicate AND the {@code other} predicate + */ + default RequestPredicate and(RequestPredicate other) { + return new RequestPredicates.AndRequestPredicate(this, other); + } + + /** + * Return a predicate that represents the logical negation of this predicate. + * @return a predicate that represents the logical negation of this predicate + */ + default RequestPredicate negate() { + return new RequestPredicates.NegateRequestPredicate(this); + } + + /** + * Return a composed request predicate that tests against both this predicate OR + * the {@code other} predicate. When evaluating the composed predicate, if this + * predicate is {@code true}, then the {@code other} predicate is not evaluated. + * @param other a predicate that will be logically-ORed with this predicate + * @return a predicate composed of this predicate OR the {@code other} predicate + */ + default RequestPredicate or(RequestPredicate other) { + return new RequestPredicates.OrRequestPredicate(this, other); + } + + /** + * Transform the given request into a request used for a nested route. For instance, + * a path-based predicate can return a {@code ServerRequest} with a the path remaining + * after a match. + *

The default implementation returns an {@code Optional} wrapping the given request if + * {@link #test(ServerRequest)} evaluates to {@code true}; or {@link Optional#empty()} + * if it evaluates to {@code false}. + * @param request the request to be nested + * @return the nested request + * @see RouterFunctions#nest(RequestPredicate, RouterFunction) + */ + default Optional nest(ServerRequest request) { + return (test(request) ? Optional.of(request) : Optional.empty()); + } + + /** + * Accept the given visitor. Default implementation calls + * {@link RequestPredicates.Visitor#unknown(RequestPredicate)}; composed {@code RequestPredicate} + * implementations are expected to call {@code accept} for all components that make up this + * request predicate. + * @param visitor the visitor to accept + */ + default void accept(RequestPredicates.Visitor visitor) { + visitor.unknown(this); + } +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java new file mode 100644 index 0000000000..c911cf0c16 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java @@ -0,0 +1,1044 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.security.Principal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Predicate; +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.PathContainer; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriBuilder; +import org.springframework.web.util.UriUtils; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * Implementations of {@link RequestPredicate} that implement various useful + * request matching operations, such as matching based on path, HTTP method, etc. + * + * @author Arjen Poutsma + * @since 5.2 + */ +public abstract class RequestPredicates { + + private static final Log logger = LogFactory.getLog(RequestPredicates.class); + + private static final PathPatternParser DEFAULT_PATTERN_PARSER = new PathPatternParser(); + + + /** + * Return a {@code RequestPredicate} that always matches. + * @return a predicate that always matches + */ + public static RequestPredicate all() { + return request -> true; + } + + /** + * Return a {@code RequestPredicate} that matches if the request's + * HTTP method is equal to the given method. + * @param httpMethod the HTTP method to match against + * @return a predicate that tests against the given HTTP method + */ + public static RequestPredicate method(HttpMethod httpMethod) { + return new HttpMethodPredicate(httpMethod); + } + + /** + * Return a {@code RequestPredicate} that matches if the request's + * HTTP method is equal to one the of the given methods. + * @param httpMethods the HTTP methods to match against + * @return a predicate that tests against the given HTTP methods + */ + public static RequestPredicate methods(HttpMethod... httpMethods) { + return new HttpMethodPredicate(httpMethods); + } + + /** + * Return a {@code RequestPredicate} that tests the request path + * against the given path pattern. + * @param pattern the pattern to match to + * @return a predicate that tests against the given path pattern + */ + public static RequestPredicate path(String pattern) { + Assert.notNull(pattern, "'pattern' must not be null"); + return pathPredicates(DEFAULT_PATTERN_PARSER).apply(pattern); + } + + /** + * Return a function that creates new path-matching {@code RequestPredicates} + * from pattern Strings using the given {@link PathPatternParser}. + *

This method can be used to specify a non-default, customized + * {@code PathPatternParser} when resolving path patterns. + * @param patternParser the parser used to parse patterns given to the returned function + * @return a function that resolves a pattern String into a path-matching + * {@code RequestPredicates} instance + */ + public static Function pathPredicates(PathPatternParser patternParser) { + Assert.notNull(patternParser, "PathPatternParser must not be null"); + return pattern -> new PathPatternPredicate(patternParser.parse(pattern)); + } + + /** + * Return a {@code RequestPredicate} that tests the request's headers + * against the given headers predicate. + * @param headersPredicate a predicate that tests against the request headers + * @return a predicate that tests against the given header predicate + */ + public static RequestPredicate headers(Predicate headersPredicate) { + return new HeadersPredicate(headersPredicate); + } + + /** + * Return a {@code RequestPredicate} that tests if the request's + * {@linkplain ServerRequest.Headers#contentType() content type} is + * {@linkplain MediaType#includes(MediaType) included} by any of the given media types. + * @param mediaTypes the media types to match the request's content type against + * @return a predicate that tests the request's content type against the given media types + */ + public static RequestPredicate contentType(MediaType... mediaTypes) { + Assert.notEmpty(mediaTypes, "'mediaTypes' must not be empty"); + return new ContentTypePredicate(mediaTypes); + } + + /** + * Return a {@code RequestPredicate} that tests if the request's + * {@linkplain ServerRequest.Headers#accept() accept} header is + * {@linkplain MediaType#isCompatibleWith(MediaType) compatible} with any of the given media types. + * @param mediaTypes the media types to match the request's accept header against + * @return a predicate that tests the request's accept header against the given media types + */ + public static RequestPredicate accept(MediaType... mediaTypes) { + Assert.notEmpty(mediaTypes, "'mediaTypes' must not be empty"); + return new AcceptPredicate(mediaTypes); + } + + /** + * Return a {@code RequestPredicate} that matches if request's HTTP method is {@code GET} + * and the given {@code pattern} matches against the request path. + * @param pattern the path pattern to match against + * @return a predicate that matches if the request method is GET and if the given pattern + * matches against the request path + */ + public static RequestPredicate GET(String pattern) { + return method(HttpMethod.GET).and(path(pattern)); + } + + /** + * Return a {@code RequestPredicate} that matches if request's HTTP method is {@code HEAD} + * and the given {@code pattern} matches against the request path. + * @param pattern the path pattern to match against + * @return a predicate that matches if the request method is HEAD and if the given pattern + * matches against the request path + */ + public static RequestPredicate HEAD(String pattern) { + return method(HttpMethod.HEAD).and(path(pattern)); + } + + /** + * Return a {@code RequestPredicate} that matches if request's HTTP method is {@code POST} + * and the given {@code pattern} matches against the request path. + * @param pattern the path pattern to match against + * @return a predicate that matches if the request method is POST and if the given pattern + * matches against the request path + */ + public static RequestPredicate POST(String pattern) { + return method(HttpMethod.POST).and(path(pattern)); + } + + /** + * Return a {@code RequestPredicate} that matches if request's HTTP method is {@code PUT} + * and the given {@code pattern} matches against the request path. + * @param pattern the path pattern to match against + * @return a predicate that matches if the request method is PUT and if the given pattern + * matches against the request path + */ + public static RequestPredicate PUT(String pattern) { + return method(HttpMethod.PUT).and(path(pattern)); + } + + /** + * Return a {@code RequestPredicate} that matches if request's HTTP method is {@code PATCH} + * and the given {@code pattern} matches against the request path. + * @param pattern the path pattern to match against + * @return a predicate that matches if the request method is PATCH and if the given pattern + * matches against the request path + */ + public static RequestPredicate PATCH(String pattern) { + return method(HttpMethod.PATCH).and(path(pattern)); + } + + /** + * Return a {@code RequestPredicate} that matches if request's HTTP method is {@code DELETE} + * and the given {@code pattern} matches against the request path. + * @param pattern the path pattern to match against + * @return a predicate that matches if the request method is DELETE and if the given pattern + * matches against the request path + */ + public static RequestPredicate DELETE(String pattern) { + return method(HttpMethod.DELETE).and(path(pattern)); + } + + /** + * Return a {@code RequestPredicate} that matches if request's HTTP method is {@code OPTIONS} + * and the given {@code pattern} matches against the request path. + * @param pattern the path pattern to match against + * @return a predicate that matches if the request method is OPTIONS and if the given pattern + * matches against the request path + */ + public static RequestPredicate OPTIONS(String pattern) { + return method(HttpMethod.OPTIONS).and(path(pattern)); + } + + /** + * Return a {@code RequestPredicate} that matches if the request's path has the given extension. + * @param extension the path extension to match against, ignoring case + * @return a predicate that matches if the request's path has the given file extension + */ + public static RequestPredicate pathExtension(String extension) { + Assert.notNull(extension, "'extension' must not be null"); + return new PathExtensionPredicate(extension); + } + + /** + * Return a {@code RequestPredicate} that matches if the request's path matches the given + * predicate. + * @param extensionPredicate the predicate to test against the request path extension + * @return a predicate that matches if the given predicate matches against the request's path + * file extension + */ + public static RequestPredicate pathExtension(Predicate extensionPredicate) { + return new PathExtensionPredicate(extensionPredicate); + } + + /** + * Return a {@code RequestPredicate} that matches if the request's parameter of the given name + * has the given value. + * @param name the name of the parameter to test against + * @param value the value of the parameter to test against + * @return a predicate that matches if the parameter has the given value + * @see ServerRequest#param(String) + */ + public static RequestPredicate param(String name, String value) { + return new ParamPredicate(name, value); + } + + /** + * Return a {@code RequestPredicate} that tests the request's parameter of the given name + * against the given predicate. + * @param name the name of the parameter to test against + * @param predicate predicate to test against the parameter value + * @return a predicate that matches the given predicate against the parameter of the given name + * @see ServerRequest#param(String) + */ + public static RequestPredicate param(String name, Predicate predicate) { + return new ParamPredicate(name, predicate); + } + + + private static void traceMatch(String prefix, Object desired, @Nullable Object actual, boolean match) { + if (logger.isTraceEnabled()) { + logger.trace(String.format("%s \"%s\" %s against value \"%s\"", + prefix, desired, match ? "matches" : "does not match", actual)); + } + } + + private static void restoreAttributes(ServerRequest request, Map attributes) { + request.attributes().clear(); + request.attributes().putAll(attributes); + } + + private static Map mergePathVariables(Map oldVariables, + Map newVariables) { + + if (!newVariables.isEmpty()) { + Map mergedVariables = new LinkedHashMap<>(oldVariables); + mergedVariables.putAll(newVariables); + return mergedVariables; + } + else { + return oldVariables; + } + } + + private static PathPattern mergePatterns(@Nullable PathPattern oldPattern, PathPattern newPattern) { + if (oldPattern != null) { + return oldPattern.combine(newPattern); + } + else { + return newPattern; + } + + } + + + /** + * Receives notifications from the logical structure of request predicates. + */ + public interface Visitor { + + /** + * Receive notification of an HTTP method predicate. + * @param methods the HTTP methods that make up the predicate + * @see RequestPredicates#method(HttpMethod) + */ + void method(Set methods); + + /** + * Receive notification of an path predicate. + * @param pattern the path pattern that makes up the predicate + * @see RequestPredicates#path(String) + */ + void path(String pattern); + + /** + * Receive notification of an path extension predicate. + * @param extension the path extension that makes up the predicate + * @see RequestPredicates#pathExtension(String) + */ + void pathExtension(String extension); + + /** + * Receive notification of a HTTP header predicate. + * @param name the name of the HTTP header to check + * @param value the desired value of the HTTP header + * @see RequestPredicates#headers(Predicate) + * @see RequestPredicates#contentType(MediaType...) + * @see RequestPredicates#accept(MediaType...) + */ + void header(String name, String value); + + /** + * Receive notification of a parameter predicate. + * @param name the name of the parameter + * @param value the desired value of the parameter + * @see RequestPredicates#param(String, String) + */ + void param(String name, String value); + + /** + * Receive first notification of a logical AND predicate. + * The first subsequent notification will contain the left-hand side of the AND-predicate; + * followed by {@link #and()}, followed by the right-hand side, followed by {@link #endAnd()}. + * @see RequestPredicate#and(RequestPredicate) + */ + void startAnd(); + + /** + * Receive "middle" notification of a logical AND predicate. + * The following notification contains the right-hand side, followed by {@link #endAnd()}. + * @see RequestPredicate#and(RequestPredicate) + */ + void and(); + + /** + * Receive last notification of a logical AND predicate. + * @see RequestPredicate#and(RequestPredicate) + */ + void endAnd(); + + /** + * Receive first notification of a logical OR predicate. + * The first subsequent notification will contain the left-hand side of the OR-predicate; + * the second notification contains the right-hand side, followed by {@link #endOr()}. + * @see RequestPredicate#or(RequestPredicate) + */ + void startOr(); + + /** + * Receive "middle" notification of a logical OR predicate. + * The following notification contains the right-hand side, followed by {@link #endOr()}. + * @see RequestPredicate#or(RequestPredicate) + */ + void or(); + + /** + * Receive last notification of a logical OR predicate. + * @see RequestPredicate#or(RequestPredicate) + */ + void endOr(); + + /** + * Receive first notification of a negated predicate. + * The first subsequent notification will contain the negated predicated, followed + * by {@link #endNegate()}. + * @see RequestPredicate#negate() + */ + void startNegate(); + + /** + * Receive last notification of a negated predicate. + * @see RequestPredicate#negate() + */ + void endNegate(); + + /** + * Receive first notification of an unknown predicate. + */ + void unknown(RequestPredicate predicate); + } + + private static class HttpMethodPredicate implements RequestPredicate { + + private final Set httpMethods; + + + public HttpMethodPredicate(HttpMethod httpMethod) { + Assert.notNull(httpMethod, "HttpMethod must not be null"); + this.httpMethods = EnumSet.of(httpMethod); + } + + public HttpMethodPredicate(HttpMethod... httpMethods) { + Assert.notEmpty(httpMethods, "HttpMethods must not be empty"); + this.httpMethods = EnumSet.copyOf(Arrays.asList(httpMethods)); + } + + @Override + public boolean test(ServerRequest request) { + boolean match = this.httpMethods.contains(request.method()); + traceMatch("Method", this.httpMethods, request.method(), match); + return match; + } + + @Override + public void accept(Visitor visitor) { + visitor.method(Collections.unmodifiableSet(this.httpMethods)); + } + + @Override + public String toString() { + if (this.httpMethods.size() == 1) { + return this.httpMethods.iterator().next().toString(); + } + else { + return this.httpMethods.toString(); + } + } + } + + + private static class PathPatternPredicate implements RequestPredicate { + + private final PathPattern pattern; + + public PathPatternPredicate(PathPattern pattern) { + Assert.notNull(pattern, "'pattern' must not be null"); + this.pattern = pattern; + } + + @Override + public boolean test(ServerRequest request) { + PathContainer pathContainer = request.pathContainer(); + PathPattern.PathMatchInfo info = this.pattern.matchAndExtract(pathContainer); + traceMatch("Pattern", this.pattern.getPatternString(), request.path(), info != null); + if (info != null) { + mergeAttributes(request, info.getUriVariables(), this.pattern); + return true; + } + else { + return false; + } + } + + private static void mergeAttributes(ServerRequest request, Map variables, + PathPattern pattern) { + Map pathVariables = mergePathVariables(request.pathVariables(), variables); + request.attributes().put(RouterFunctions.URI_TEMPLATE_VARIABLES_ATTRIBUTE, + Collections.unmodifiableMap(pathVariables)); + + pattern = mergePatterns( + (PathPattern) request.attributes().get(RouterFunctions.MATCHING_PATTERN_ATTRIBUTE), + pattern); + request.attributes().put(RouterFunctions.MATCHING_PATTERN_ATTRIBUTE, pattern); + } + @Override + public Optional nest(ServerRequest request) { + return Optional.ofNullable(this.pattern.matchStartOfPath(request.pathContainer())) + .map(info -> new SubPathServerRequestWrapper(request, info, this.pattern)); + } + + @Override + public void accept(Visitor visitor) { + visitor.path(this.pattern.getPatternString()); + } + + @Override + public String toString() { + return this.pattern.getPatternString(); + } + } + + + private static class HeadersPredicate implements RequestPredicate { + + private final Predicate headersPredicate; + + public HeadersPredicate(Predicate headersPredicate) { + Assert.notNull(headersPredicate, "Predicate must not be null"); + this.headersPredicate = headersPredicate; + } + + @Override + public boolean test(ServerRequest request) { + return this.headersPredicate.test(request.headers()); + } + + @Override + public String toString() { + return this.headersPredicate.toString(); + } + } + + private static class ContentTypePredicate extends HeadersPredicate { + + private final Set mediaTypes; + + public ContentTypePredicate(MediaType... mediaTypes) { + this(new HashSet<>(Arrays.asList(mediaTypes))); + } + + private ContentTypePredicate(Set mediaTypes) { + super(headers -> { + MediaType contentType = + headers.contentType().orElse(MediaType.APPLICATION_OCTET_STREAM); + boolean match = mediaTypes.stream() + .anyMatch(mediaType -> mediaType.includes(contentType)); + traceMatch("Content-Type", mediaTypes, contentType, match); + return match; + }); + this.mediaTypes = mediaTypes; + } + + @Override + public void accept(Visitor visitor) { + visitor.header(HttpHeaders.CONTENT_TYPE, + (this.mediaTypes.size() == 1) ? + this.mediaTypes.iterator().next().toString() : + this.mediaTypes.toString()); + } + + @Override + public String toString() { + return String.format("Content-Type: %s", + (this.mediaTypes.size() == 1) ? + this.mediaTypes.iterator().next().toString() : + this.mediaTypes.toString()); + } + } + + private static class AcceptPredicate extends HeadersPredicate { + + private final Set mediaTypes; + + public AcceptPredicate(MediaType... mediaTypes) { + this(new HashSet<>(Arrays.asList(mediaTypes))); + } + + private AcceptPredicate(Set mediaTypes) { + super(headers -> { + List acceptedMediaTypes = acceptedMediaTypes(headers); + boolean match = acceptedMediaTypes.stream() + .anyMatch(acceptedMediaType -> mediaTypes.stream() + .anyMatch(acceptedMediaType::isCompatibleWith)); + traceMatch("Accept", mediaTypes, acceptedMediaTypes, match); + return match; + }); + this.mediaTypes = mediaTypes; + } + + @NonNull + private static List acceptedMediaTypes(ServerRequest.Headers headers) { + List acceptedMediaTypes = headers.accept(); + if (acceptedMediaTypes.isEmpty()) { + acceptedMediaTypes = Collections.singletonList(MediaType.ALL); + } + else { + MediaType.sortBySpecificityAndQuality(acceptedMediaTypes); + } + return acceptedMediaTypes; + } + + @Override + public void accept(Visitor visitor) { + visitor.header(HttpHeaders.ACCEPT, + (this.mediaTypes.size() == 1) ? + this.mediaTypes.iterator().next().toString() : + this.mediaTypes.toString()); + } + + @Override + public String toString() { + return String.format("Accept: %s", + (this.mediaTypes.size() == 1) ? + this.mediaTypes.iterator().next().toString() : + this.mediaTypes.toString()); + } + } + + private static class PathExtensionPredicate implements RequestPredicate { + + private final Predicate extensionPredicate; + + @Nullable + private final String extension; + public PathExtensionPredicate(Predicate extensionPredicate) { + Assert.notNull(extensionPredicate, "Predicate must not be null"); + this.extensionPredicate = extensionPredicate; + this.extension = null; + } + + public PathExtensionPredicate(String extension) { + Assert.notNull(extension, "Extension must not be null"); + + this.extensionPredicate = s -> { + boolean match = extension.equalsIgnoreCase(s); + traceMatch("Extension", extension, s, match); + return match; + }; + this.extension = extension; + } + + @Override + public boolean test(ServerRequest request) { + String pathExtension = UriUtils.extractFileExtension(request.path()); + return this.extensionPredicate.test(pathExtension); + } + + @Override + public void accept(Visitor visitor) { + visitor.pathExtension( + (this.extension != null) ? + this.extension : + this.extensionPredicate.toString()); + } + + @Override + public String toString() { + return String.format("*.%s", + (this.extension != null) ? + this.extension : + this.extensionPredicate); + } + + } + + + private static class ParamPredicate implements RequestPredicate { + + private final String name; + + private final Predicate valuePredicate; + + @Nullable + private final String value; + + public ParamPredicate(String name, Predicate valuePredicate) { + Assert.notNull(name, "Name must not be null"); + Assert.notNull(valuePredicate, "Predicate must not be null"); + this.name = name; + this.valuePredicate = valuePredicate; + this.value = null; + } + + public ParamPredicate(String name, String value) { + Assert.notNull(name, "Name must not be null"); + Assert.notNull(value, "Value must not be null"); + this.name = name; + this.valuePredicate = value::equals; + this.value = value; + } + + @Override + public boolean test(ServerRequest request) { + Optional s = request.param(this.name); + return s.filter(this.valuePredicate).isPresent(); + } + + @Override + public void accept(Visitor visitor) { + visitor.param(this.name, + (this.value != null) ? + this.value : + this.valuePredicate.toString()); + } + + @Override + public String toString() { + return String.format("?%s %s", this.name, + (this.value != null) ? + this.value : + this.valuePredicate); + } + } + + + /** + * {@link RequestPredicate} for where both {@code left} and {@code right} predicates + * must match. + */ + static class AndRequestPredicate implements RequestPredicate { + + private final RequestPredicate left; + + private final RequestPredicate right; + + public AndRequestPredicate(RequestPredicate left, RequestPredicate right) { + Assert.notNull(left, "Left RequestPredicate must not be null"); + Assert.notNull(right, "Right RequestPredicate must not be null"); + this.left = left; + this.right = right; + } + + @Override + public boolean test(ServerRequest request) { + Map oldAttributes = new HashMap<>(request.attributes()); + + if (this.left.test(request) && this.right.test(request)) { + return true; + } + restoreAttributes(request, oldAttributes); + return false; + } + + @Override + public Optional nest(ServerRequest request) { + return this.left.nest(request).flatMap(this.right::nest); + } + + @Override + public void accept(Visitor visitor) { + visitor.startAnd(); + this.left.accept(visitor); + visitor.and(); + this.right.accept(visitor); + visitor.endAnd(); + } + + @Override + public String toString() { + return String.format("(%s && %s)", this.left, this.right); + } + } + + /** + * {@link RequestPredicate} that negates a delegate predicate. + */ + static class NegateRequestPredicate implements RequestPredicate { + private final RequestPredicate delegate; + + public NegateRequestPredicate(RequestPredicate delegate) { + Assert.notNull(delegate, "Delegate must not be null"); + this.delegate = delegate; + } + + @Override + public boolean test(ServerRequest request) { + Map oldAttributes = new HashMap<>(request.attributes()); + boolean result = !this.delegate.test(request); + if (!result) { + restoreAttributes(request, oldAttributes); + } + return result; + } + + @Override + public void accept(Visitor visitor) { + visitor.startNegate(); + this.delegate.accept(visitor); + visitor.endNegate(); + } + + @Override + public String toString() { + return "!" + this.delegate.toString(); + } + } + + /** + * {@link RequestPredicate} where either {@code left} or {@code right} predicates + * may match. + */ + static class OrRequestPredicate implements RequestPredicate { + + private final RequestPredicate left; + + private final RequestPredicate right; + + public OrRequestPredicate(RequestPredicate left, RequestPredicate right) { + Assert.notNull(left, "Left RequestPredicate must not be null"); + Assert.notNull(right, "Right RequestPredicate must not be null"); + this.left = left; + this.right = right; + } + + @Override + public boolean test(ServerRequest request) { + Map oldAttributes = new HashMap<>(request.attributes()); + + if (this.left.test(request)) { + return true; + } + else { + restoreAttributes(request, oldAttributes); + if (this.right.test(request)) { + return true; + } + } + restoreAttributes(request, oldAttributes); + return false; + } + + @Override + public Optional nest(ServerRequest request) { + Optional leftResult = this.left.nest(request); + if (leftResult.isPresent()) { + return leftResult; + } + else { + return this.right.nest(request); + } + } + + @Override + public void accept(Visitor visitor) { + visitor.startOr(); + this.left.accept(visitor); + visitor.or(); + this.right.accept(visitor); + visitor.endOr(); + } + + @Override + public String toString() { + return String.format("(%s || %s)", this.left, this.right); + } + } + + + private static class SubPathServerRequestWrapper implements ServerRequest { + + private final ServerRequest request; + + private final PathContainer pathContainer; + + private final Map attributes; + + public SubPathServerRequestWrapper(ServerRequest request, + PathPattern.PathRemainingMatchInfo info, PathPattern pattern) { + this.request = request; + this.pathContainer = new SubPathContainer(info.getPathRemaining()); + this.attributes = mergeAttributes(request, info.getUriVariables(), pattern); + } + + private static Map mergeAttributes(ServerRequest request, + Map pathVariables, PathPattern pattern) { + Map result = new ConcurrentHashMap<>(request.attributes()); + + result.put(RouterFunctions.URI_TEMPLATE_VARIABLES_ATTRIBUTE, + mergePathVariables(request.pathVariables(), pathVariables)); + + pattern = mergePatterns( + (PathPattern) request.attributes().get(RouterFunctions.MATCHING_PATTERN_ATTRIBUTE), + pattern); + result.put(RouterFunctions.MATCHING_PATTERN_ATTRIBUTE, pattern); + return result; + } + + @Override + public HttpMethod method() { + return this.request.method(); + } + + @Override + public String methodName() { + return this.request.methodName(); + } + + @Override + public URI uri() { + return this.request.uri(); + } + + @Override + public UriBuilder uriBuilder() { + return this.request.uriBuilder(); + } + + @Override + public String path() { + return this.pathContainer.value(); + } + + @Override + public PathContainer pathContainer() { + return this.pathContainer; + } + + @Override + public Headers headers() { + return this.request.headers(); + } + + @Override + public MultiValueMap cookies() { + return this.request.cookies(); + } + + @Override + public Optional remoteAddress() { + return this.request.remoteAddress(); + } + + @Override + public List> messageConverters() { + return this.request.messageConverters(); + } + + @Override + public T body(Class bodyType) throws ServletException, IOException { + return this.request.body(bodyType); + } + + @Override + public T body(ParameterizedTypeReference bodyType) + throws ServletException, IOException { + return this.request.body(bodyType); + } + + @Override + public Optional attribute(String name) { + return this.request.attribute(name); + } + + @Override + public Map attributes() { + return this.attributes; + } + + @Override + public Optional param(String name) { + return this.request.param(name); + } + + @Override + public MultiValueMap params() { + return this.request.params(); + } + + @Override + @SuppressWarnings("unchecked") + public Map pathVariables() { + return (Map) this.attributes.getOrDefault( + RouterFunctions.URI_TEMPLATE_VARIABLES_ATTRIBUTE, Collections.emptyMap()); + } + + @Override + public HttpSession session() { + return this.request.session(); + } + + + + @Override + public Optional principal() { + return this.request.principal(); + } + + @Override + public HttpServletRequest servletRequest() { + return this.request.servletRequest(); + } + + @Override + public String toString() { + return method() + " " + path(); + } + + private static class SubPathContainer implements PathContainer { + + private static final PathContainer.Separator SEPARATOR = () -> "/"; + + + private final String value; + + private final List elements; + + public SubPathContainer(PathContainer original) { + this.value = prefixWithSlash(original.value()); + this.elements = prependWithSeparator(original.elements()); + } + + private static String prefixWithSlash(String path) { + if (!path.startsWith("/")) { + path = "/" + path; + } + return path; + } + + private static List prependWithSeparator(List elements) { + List result = new ArrayList<>(elements); + if (result.isEmpty() || !(result.get(0) instanceof Separator)) { + result.add(0, SEPARATOR); + } + return Collections.unmodifiableList(result); + } + + + @Override + public String value() { + return this.value; + } + + @Override + public List elements() { + return this.elements; + } + } + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ResourceHandlerFunction.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ResourceHandlerFunction.java new file mode 100644 index 0000000000..9ad8904cf4 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ResourceHandlerFunction.java @@ -0,0 +1,137 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.util.EnumSet; +import java.util.Set; + +import org.springframework.core.io.Resource; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.lang.Nullable; + +/** + * Resource-based implementation of {@link HandlerFunction}. + * + * @author Arjen Poutsma + * @since 5.2 + */ +class ResourceHandlerFunction implements HandlerFunction { + + private static final Set SUPPORTED_METHODS = + EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS); + + + private final Resource resource; + + + public ResourceHandlerFunction(Resource resource) { + this.resource = resource; + } + + + @Override + public ServerResponse handle(ServerRequest request) { + HttpMethod method = request.method(); + if (method != null) { + switch (method) { + case GET: + return EntityResponse.fromObject(this.resource).build(); + case HEAD: + Resource headResource = new HeadMethodResource(this.resource); + return EntityResponse.fromObject(headResource).build(); + case OPTIONS: + return ServerResponse.ok() + .allow(SUPPORTED_METHODS).build(); + } + } + return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED) + .allow(SUPPORTED_METHODS).build(); + } + + + private static class HeadMethodResource implements Resource { + + private static final byte[] EMPTY = new byte[0]; + + private final Resource delegate; + + public HeadMethodResource(Resource delegate) { + this.delegate = delegate; + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(EMPTY); + } + + // delegation + + @Override + public boolean exists() { + return this.delegate.exists(); + } + + @Override + public URL getURL() throws IOException { + return this.delegate.getURL(); + } + + @Override + public URI getURI() throws IOException { + return this.delegate.getURI(); + } + + @Override + public File getFile() throws IOException { + return this.delegate.getFile(); + } + + @Override + public long contentLength() throws IOException { + return this.delegate.contentLength(); + } + + @Override + public long lastModified() throws IOException { + return this.delegate.lastModified(); + } + + @Override + public Resource createRelative(String relativePath) throws IOException { + return this.delegate.createRelative(relativePath); + } + + @Override + @Nullable + public String getFilename() { + return this.delegate.getFilename(); + } + + @Override + public String getDescription() { + return this.delegate.getDescription(); + } + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunction.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunction.java new file mode 100644 index 0000000000..094748ce18 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunction.java @@ -0,0 +1,117 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.util.Optional; + +/** + * Represents a function that routes to a {@linkplain HandlerFunction handler function}. + * + * @author Arjen Poutsma + * @since 5.2 + * @param the type of the {@linkplain HandlerFunction handler function} to route to + * @see RouterFunctions + */ +@FunctionalInterface +public interface RouterFunction { + + /** + * Return the {@linkplain HandlerFunction handler function} that matches the given request. + * @param request the request to route + * @return an {@code Optional} describing the {@code HandlerFunction} that matches this request, + * or an empty {@code Optional} if there is no match + */ + Optional> route(ServerRequest request); + + /** + * Return a composed routing function that first invokes this function, + * and then invokes the {@code other} function (of the same response type {@code T}) + * if this route had {@linkplain Optional#empty() no result}. + * @param other the function of type {@code T} to apply when this function has no result + * @return a composed function that first routes with this function and then the + * {@code other} function if this function has no result + * @see #andOther(RouterFunction) + */ + default RouterFunction and(RouterFunction other) { + return new RouterFunctions.SameComposedRouterFunction<>(this, other); + } + + /** + * Return a composed routing function that first invokes this function, + * and then invokes the {@code other} function (of a different response type) if this route had + * {@linkplain Optional#empty() no result}. + * @param other the function to apply when this function has no result + * @return a composed function that first routes with this function and then the + * {@code other} function if this function has no result + * @see #and(RouterFunction) + */ + default RouterFunction andOther(RouterFunction other) { + return new RouterFunctions.DifferentComposedRouterFunction(this, other); + } + + /** + * Return a composed routing function that routes to the given handler function if this + * route does not match and the given request predicate applies. This method is a convenient + * combination of {@link #and(RouterFunction)} and + * {@link RouterFunctions#route(RequestPredicate, HandlerFunction)}. + * @param predicate the predicate to test if this route does not match + * @param handlerFunction the handler function to route to if this route does not match and + * the predicate applies + * @return a composed function that route to {@code handlerFunction} if this route does not + * match and if {@code predicate} applies + */ + default RouterFunction andRoute(RequestPredicate predicate, HandlerFunction handlerFunction) { + return and(RouterFunctions.route(predicate, handlerFunction)); + } + + /** + * Return a composed routing function that routes to the given router function if this + * route does not match and the given request predicate applies. This method is a convenient + * combination of {@link #and(RouterFunction)} and + * {@link RouterFunctions#nest(RequestPredicate, RouterFunction)}. + * @param predicate the predicate to test if this route does not match + * @param routerFunction the router function to route to if this route does not match and + * the predicate applies + * @return a composed function that route to {@code routerFunction} if this route does not + * match and if {@code predicate} applies + */ + default RouterFunction andNest(RequestPredicate predicate, RouterFunction routerFunction) { + return and(RouterFunctions.nest(predicate, routerFunction)); + } + + /** + * Filter all {@linkplain HandlerFunction handler functions} routed by this function with the given + * {@linkplain HandlerFilterFunction filter function}. + * @param the filter return type + * @param filterFunction the filter to apply + * @return the filtered routing function + */ + default RouterFunction filter(HandlerFilterFunction filterFunction) { + return new RouterFunctions.FilteredRouterFunction<>(this, filterFunction); + } + + /** + * Accept the given visitor. Default implementation calls + * {@link RouterFunctions.Visitor#unknown(RouterFunction)}; composed {@code RouterFunction} + * implementations are expected to call {@code accept} for all components that make up this + * router function. + * @param visitor the visitor to accept + */ + default void accept(RouterFunctions.Visitor visitor) { + visitor.unknown(this); + } +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctionBuilder.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctionBuilder.java new file mode 100644 index 0000000000..6f1cadb5a1 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctionBuilder.java @@ -0,0 +1,251 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; + +/** + * Default implementation of {@link RouterFunctions.Builder}. + * + * @author Arjen Poutsma + * @since 5.2 + */ +class RouterFunctionBuilder implements RouterFunctions.Builder { + + private List> routerFunctions = new ArrayList<>(); + + private List> filterFunctions = new ArrayList<>(); + + + @Override + public RouterFunctions.Builder add(RouterFunction routerFunction) { + Assert.notNull(routerFunction, "RouterFunction must not be null"); + this.routerFunctions.add(routerFunction); + return this; + } + + private RouterFunctions.Builder add(RequestPredicate predicate, + HandlerFunction handlerFunction) { + + this.routerFunctions.add(RouterFunctions.route(predicate, handlerFunction)); + return this; + } + + @Override + public RouterFunctions.Builder GET(String pattern, HandlerFunction handlerFunction) { + return add(RequestPredicates.GET(pattern), handlerFunction); + } + + @Override + public RouterFunctions.Builder GET(String pattern, RequestPredicate predicate, + HandlerFunction handlerFunction) { + + return add(RequestPredicates.GET(pattern).and(predicate), handlerFunction); + } + + @Override + public RouterFunctions.Builder HEAD(String pattern, HandlerFunction handlerFunction) { + return add(RequestPredicates.HEAD(pattern), handlerFunction); + } + + @Override + public RouterFunctions.Builder HEAD(String pattern, RequestPredicate predicate, + HandlerFunction handlerFunction) { + + return add(RequestPredicates.HEAD(pattern).and(predicate), handlerFunction); + } + + @Override + public RouterFunctions.Builder POST(String pattern, HandlerFunction handlerFunction) { + return add(RequestPredicates.POST(pattern), handlerFunction); + } + + @Override + public RouterFunctions.Builder POST(String pattern, RequestPredicate predicate, + HandlerFunction handlerFunction) { + + return add(RequestPredicates.POST(pattern).and(predicate), handlerFunction); + } + + @Override + public RouterFunctions.Builder PUT(String pattern, HandlerFunction handlerFunction) { + return add(RequestPredicates.PUT(pattern), handlerFunction); + } + + @Override + public RouterFunctions.Builder PUT(String pattern, RequestPredicate predicate, + HandlerFunction handlerFunction) { + + return add(RequestPredicates.PUT(pattern).and(predicate), handlerFunction); + } + + @Override + public RouterFunctions.Builder PATCH(String pattern, HandlerFunction handlerFunction) { + return add(RequestPredicates.PATCH(pattern), handlerFunction); + } + + @Override + public RouterFunctions.Builder PATCH(String pattern, RequestPredicate predicate, + HandlerFunction handlerFunction) { + + return add(RequestPredicates.PATCH(pattern).and(predicate), handlerFunction); + } + + @Override + public RouterFunctions.Builder DELETE(String pattern, HandlerFunction handlerFunction) { + return add(RequestPredicates.DELETE(pattern), handlerFunction); + } + + @Override + public RouterFunctions.Builder DELETE(String pattern, RequestPredicate predicate, + HandlerFunction handlerFunction) { + + return add(RequestPredicates.DELETE(pattern).and(predicate), handlerFunction); + } + + @Override + public RouterFunctions.Builder OPTIONS(String pattern, HandlerFunction handlerFunction) { + return add(RequestPredicates.OPTIONS(pattern), handlerFunction); + } + + @Override + public RouterFunctions.Builder OPTIONS(String pattern, RequestPredicate predicate, + HandlerFunction handlerFunction) { + + return add(RequestPredicates.OPTIONS(pattern).and(predicate), handlerFunction); + } + + @Override + public RouterFunctions.Builder resources(String pattern, Resource location) { + return add(RouterFunctions.resources(pattern, location)); + } + + @Override + public RouterFunctions.Builder resources(Function> lookupFunction) { + return add(RouterFunctions.resources(lookupFunction)); + } + + @Override + public RouterFunctions.Builder nest(RequestPredicate predicate, + Consumer builderConsumer) { + + Assert.notNull(builderConsumer, "Consumer must not be null"); + + RouterFunctionBuilder nestedBuilder = new RouterFunctionBuilder(); + builderConsumer.accept(nestedBuilder); + RouterFunction nestedRoute = nestedBuilder.build(); + this.routerFunctions.add(RouterFunctions.nest(predicate, nestedRoute)); + return this; + } + + @Override + public RouterFunctions.Builder nest(RequestPredicate predicate, + Supplier> routerFunctionSupplier) { + + Assert.notNull(routerFunctionSupplier, "RouterFunction Supplier must not be null"); + + RouterFunction nestedRoute = routerFunctionSupplier.get(); + this.routerFunctions.add(RouterFunctions.nest(predicate, nestedRoute)); + return this; + } + + @Override + public RouterFunctions.Builder path(String pattern, + Consumer builderConsumer) { + + return nest(RequestPredicates.path(pattern), builderConsumer); + } + + @Override + public RouterFunctions.Builder path(String pattern, + Supplier> routerFunctionSupplier) { + + return nest(RequestPredicates.path(pattern), routerFunctionSupplier); + } + + @Override + public RouterFunctions.Builder filter(HandlerFilterFunction filterFunction) { + Assert.notNull(filterFunction, "HandlerFilterFunction must not be null"); + + this.filterFunctions.add(filterFunction); + return this; + } + + @Override + public RouterFunctions.Builder before(Function requestProcessor) { + Assert.notNull(requestProcessor, "RequestProcessor must not be null"); + return filter(HandlerFilterFunction.ofRequestProcessor(requestProcessor)); + } + + @Override + public RouterFunctions.Builder after( + BiFunction responseProcessor) { + + Assert.notNull(responseProcessor, "ResponseProcessor must not be null"); + return filter(HandlerFilterFunction.ofResponseProcessor(responseProcessor)); + } + + @Override + public RouterFunctions.Builder onError(Predicate predicate, + BiFunction responseProvider) { + + Assert.notNull(predicate, "Predicate must not be null"); + Assert.notNull(responseProvider, "ResponseProvider must not be null"); + + return filter(HandlerFilterFunction.ofErrorHandler(predicate, responseProvider)); + } + + @Override + public RouterFunctions.Builder onError(Class exceptionType, + BiFunction responseProvider) { + Assert.notNull(exceptionType, "ExceptionType must not be null"); + Assert.notNull(responseProvider, "ResponseProvider must not be null"); + + return filter(HandlerFilterFunction.ofErrorHandler(exceptionType::isInstance, + responseProvider)); + } + + @Override + public RouterFunction build() { + RouterFunction result = this.routerFunctions.stream() + .reduce(RouterFunction::and) + .orElseThrow(IllegalStateException::new); + + if (this.filterFunctions.isEmpty()) { + return result; + } + else { + HandlerFilterFunction filter = + this.filterFunctions.stream() + .reduce(HandlerFilterFunction::andThen) + .orElseThrow(IllegalStateException::new); + + return result.filter(filter); + } + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctions.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctions.java new file mode 100644 index 0000000000..f6c459398c --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RouterFunctions.java @@ -0,0 +1,875 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; + +/** + * Central entry point to Spring's functional web framework. + * Exposes routing functionality, such as to {@linkplain #route() create} a + * {@code RouterFunction} using a discoverable builder-style API, to + * {@linkplain #route(RequestPredicate, HandlerFunction) create} a {@code RouterFunction} + * given a {@code RequestPredicate} and {@code HandlerFunction}, and to do further + * {@linkplain #nest(RequestPredicate, RouterFunction) subrouting} on an existing routing + * function. + * + * @author Arjen Poutsma + * @since 5.2 + */ +public abstract class RouterFunctions { + + private static final Log logger = LogFactory.getLog(RouterFunctions.class); + + /** + * Name of the request attribute that contains the {@link ServerRequest}. + */ + public static final String REQUEST_ATTRIBUTE = RouterFunctions.class.getName() + ".request"; + + /** + * Name of the request attribute that contains the URI + * templates map, mapping variable names to values. + */ + public static final String URI_TEMPLATE_VARIABLES_ATTRIBUTE = + RouterFunctions.class.getName() + ".uriTemplateVariables"; + + /** + * Name of the request attribute that contains the matching pattern, as a + * {@link org.springframework.web.util.pattern.PathPattern}. + */ + public static final String MATCHING_PATTERN_ATTRIBUTE = + RouterFunctions.class.getName() + ".matchingPattern"; + + + /** + * Offers a discoverable way to create router functions through a builder-style interface. + * @return a router function builder + */ + public static Builder route() { + return new RouterFunctionBuilder(); + } + + /** + * Route to the given handler function if the given request predicate applies. + *

For instance, the following example routes GET requests for "/user" to the + * {@code listUsers} method in {@code userController}: + *

+	 * RouterFunction<ServerResponse> route =
+	 *     RouterFunctions.route(RequestPredicates.GET("/user"), userController::listUsers);
+	 * 
+ * @param predicate the predicate to test + * @param handlerFunction the handler function to route to if the predicate applies + * @param the type of response returned by the handler function + * @return a router function that routes to {@code handlerFunction} if + * {@code predicate} evaluates to {@code true} + * @see RequestPredicates + */ + public static RouterFunction route( + RequestPredicate predicate, HandlerFunction handlerFunction) { + + return new DefaultRouterFunction<>(predicate, handlerFunction); + } + + /** + * Route to the given router function if the given request predicate applies. This method can be + * used to create nested routes, where a group of routes share a common path + * (prefix), header, or other request predicate. + *

For instance, the following example first creates a composed route that resolves to + * {@code listUsers} for a GET, and {@code createUser} for a POST. This composed route then gets + * nested with a "/user" path predicate, so that GET requests for "/user" will list users, + * and POST request for "/user" will create a new user. + *

+	 * RouterFunction<ServerResponse> userRoutes =
+	 *   RouterFunctions.route(RequestPredicates.method(HttpMethod.GET), this::listUsers)
+	 *     .andRoute(RequestPredicates.method(HttpMethod.POST), this::createUser);
+	 * RouterFunction<ServerResponse> nestedRoute =
+	 *   RouterFunctions.nest(RequestPredicates.path("/user"), userRoutes);
+	 * 
+ * @param predicate the predicate to test + * @param routerFunction the nested router function to delegate to if the predicate applies + * @param the type of response returned by the handler function + * @return a router function that routes to {@code routerFunction} if + * {@code predicate} evaluates to {@code true} + * @see RequestPredicates + */ + public static RouterFunction nest( + RequestPredicate predicate, RouterFunction routerFunction) { + + return new DefaultNestedRouterFunction<>(predicate, routerFunction); + } + + /** + * Route requests that match the given pattern to resources relative to the given root location. + * For instance + *
+	 * Resource location = new FileSystemResource("public-resources/");
+	 * RouterFunction<ServerResponse> resources = RouterFunctions.resources("/resources/**", location);
+     * 
+ * @param pattern the pattern to match + * @param location the location directory relative to which resources should be resolved + * @return a router function that routes to resources + * @see #resourceLookupFunction(String, Resource) + */ + public static RouterFunction resources(String pattern, Resource location) { + return resources(resourceLookupFunction(pattern, location)); + } + + /** + * Returns the resource lookup function used by {@link #resources(String, Resource)}. + * The returned function can be {@linkplain Function#andThen(Function) composed} on, for + * instance to return a default resource when the lookup function does not match: + *
+	 * Mono<Resource> defaultResource = Mono.just(new ClassPathResource("index.html"));
+	 * Function<ServerRequest, Mono<Resource>> lookupFunction =
+	 *   RouterFunctions.resourceLookupFunction("/resources/**", new FileSystemResource("public-resources/"))
+	 *     .andThen(resourceMono -> resourceMono.switchIfEmpty(defaultResource));
+	 * RouterFunction<ServerResponse> resources = RouterFunctions.resources(lookupFunction);
+     * 
+ * @param pattern the pattern to match + * @param location the location directory relative to which resources should be resolved + * @return the default resource lookup function for the given parameters. + */ + public static Function> resourceLookupFunction(String pattern, Resource location) { + return new PathResourceLookupFunction(pattern, location); + } + + /** + * Route to resources using the provided lookup function. If the lookup function provides a + * {@link Resource} for the given request, it will be it will be exposed using a + * {@link HandlerFunction} that handles GET, HEAD, and OPTIONS requests. + * @param lookupFunction the function to provide a {@link Resource} given the {@link ServerRequest} + * @return a router function that routes to resources + */ + public static RouterFunction resources(Function> lookupFunction) { + return new ResourcesRouterFunction(lookupFunction); + } + + @SuppressWarnings("unchecked") + static HandlerFunction cast(HandlerFunction handlerFunction) { + return (HandlerFunction) handlerFunction; + } + + + /** + * Represents a discoverable builder for router functions. + * Obtained via {@link RouterFunctions#route()}. + */ + public interface Builder { + + /** + * Adds a route to the given handler function that handles all HTTP {@code GET} requests + * that match the given pattern. + * @param pattern the pattern to match to + * @param handlerFunction the handler function to handle all {@code GET} requests that + * match {@code pattern} + * @return this builder + */ + Builder GET(String pattern, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code GET} requests + * that match the given pattern and predicate. + *

For instance, the following example routes GET requests for "/user" that accept JSON + * to the {@code listUsers} method in {@code userController}: + *

+		 * RouterFunction<ServerResponse> route =
+		 *   RouterFunctions.route()
+		 *     .GET("/user", RequestPredicates.accept(MediaType.APPLICATION_JSON), userController::listUsers)
+		 *     .build();
+		 * 
+ * @param pattern the pattern to match to + * @param predicate additional predicate to match + * @param handlerFunction the handler function to handle all {@code GET} requests that + * match {@code pattern} + * @return this builder + * @see RequestPredicates + */ + Builder GET(String pattern, RequestPredicate predicate, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code HEAD} requests + * that match the given pattern. + * @param pattern the pattern to match to + * @param handlerFunction the handler function to handle all {@code HEAD} requests that + * match {@code pattern} + * @return this builder + */ + Builder HEAD(String pattern, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code HEAD} requests + * that match the given pattern and predicate. + * @param pattern the pattern to match to + * @param predicate additional predicate to match + * @param handlerFunction the handler function to handle all {@code HEAD} requests that + * match {@code pattern} + * @return this builder + */ + Builder HEAD(String pattern, RequestPredicate predicate, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code POST} requests + * that match the given pattern. + * @param pattern the pattern to match to + * @param handlerFunction the handler function to handle all {@code POST} requests that + * match {@code pattern} + * @return this builder + */ + Builder POST(String pattern, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code POST} requests + * that match the given pattern and predicate. + *

For instance, the following example routes POST requests for "/user" that contain JSON + * to the {@code addUser} method in {@code userController}: + *

+		 * RouterFunction<ServerResponse> route =
+		 *   RouterFunctions.route()
+		 *     .POST("/user", RequestPredicates.contentType(MediaType.APPLICATION_JSON), userController::addUser)
+		 *     .build();
+		 * 
+ * @param pattern the pattern to match to + * @param predicate additional predicate to match + * @param handlerFunction the handler function to handle all {@code POST} requests that + * match {@code pattern} + * @return this builder + */ + Builder POST(String pattern, RequestPredicate predicate, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code PUT} requests + * that match the given pattern. + * @param pattern the pattern to match to + * @param handlerFunction the handler function to handle all {@code PUT} requests that + * match {@code pattern} + * @return this builder + */ + Builder PUT(String pattern, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code PUT} requests + * that match the given pattern and predicate. + *

For instance, the following example routes PUT requests for "/user" that contain JSON + * to the {@code editUser} method in {@code userController}: + *

+		 * RouterFunction<ServerResponse> route =
+		 *   RouterFunctions.route()
+		 *     .PUT("/user", RequestPredicates.contentType(MediaType.APPLICATION_JSON), userController::editUser)
+		 *     .build();
+		 * 
+ * @param pattern the pattern to match to + * @param predicate additional predicate to match + * @param handlerFunction the handler function to handle all {@code PUT} requests that + * match {@code pattern} + * @return this builder + */ + Builder PUT(String pattern, RequestPredicate predicate, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code PATCH} requests + * that match the given pattern. + * @param pattern the pattern to match to + * @param handlerFunction the handler function to handle all {@code PATCH} requests that + * match {@code pattern} + * @return this builder + */ + Builder PATCH(String pattern, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code PATCH} requests + * that match the given pattern and predicate. + *

For instance, the following example routes PATCH requests for "/user" that contain JSON + * to the {@code editUser} method in {@code userController}: + *

+		 * RouterFunction<ServerResponse> route =
+		 *   RouterFunctions.route()
+		 *     .PATCH("/user", RequestPredicates.contentType(MediaType.APPLICATION_JSON), userController::editUser)
+		 *     .build();
+		 * 
+ * @param pattern the pattern to match to + * @param predicate additional predicate to match + * @param handlerFunction the handler function to handle all {@code PATCH} requests that + * match {@code pattern} + * @return this builder + */ + Builder PATCH(String pattern, RequestPredicate predicate, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code DELETE} requests + * that match the given pattern. + * @param pattern the pattern to match to + * @param handlerFunction the handler function to handle all {@code DELETE} requests that + * match {@code pattern} + * @return this builder + */ + Builder DELETE(String pattern, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code DELETE} requests + * that match the given pattern and predicate. + * @param pattern the pattern to match to + * @param predicate additional predicate to match + * @param handlerFunction the handler function to handle all {@code DELETE} requests that + * match {@code pattern} + * @return this builder + */ + Builder DELETE(String pattern, RequestPredicate predicate, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code OPTIONS} requests + * that match the given pattern. + * @param pattern the pattern to match to + * @param handlerFunction the handler function to handle all {@code OPTIONS} requests that + * match {@code pattern} + * @return this builder + */ + Builder OPTIONS(String pattern, HandlerFunction handlerFunction); + + /** + * Adds a route to the given handler function that handles all HTTP {@code OPTIONS} requests + * that match the given pattern and predicate. + * @param pattern the pattern to match to + * @param predicate additional predicate to match + * @param handlerFunction the handler function to handle all {@code OPTIONS} requests that + * match {@code pattern} + * @return this builder + */ + Builder OPTIONS(String pattern, RequestPredicate predicate, HandlerFunction handlerFunction); + + /** + * Adds the given route to this builder. Can be used to merge externally defined router + * functions into this builder, or can be combined with + * {@link RouterFunctions#route(RequestPredicate, HandlerFunction)} + * to allow for more flexible predicate matching. + *

For instance, the following example adds the router function returned from + * {@code OrderController.routerFunction()}. + * to the {@code changeUser} method in {@code userController}: + *

+		 * RouterFunctionlt;ServerResponsegt; route =
+		 *   RouterFunctions.route()
+		 *     .GET("/users", userController::listUsers)
+		 *     .add(orderController.routerFunction());
+		 *     .build();
+		 * 
+ * @param routerFunction the router function to be added + * @return this builder + * @see RequestPredicates + */ + Builder add(RouterFunction routerFunction); + + /** + * Route requests that match the given pattern to resources relative to the given root location. + * For instance + *
+		 * Resource location = new FileSystemResource("public-resources/");
+		 * RouterFunction<ServerResponse> resources = RouterFunctions.resources("/resources/**", location);
+	     * 
+ * @param pattern the pattern to match + * @param location the location directory relative to which resources should be resolved + * @return this builder + */ + Builder resources(String pattern, Resource location); + + /** + * Route to resources using the provided lookup function. If the lookup function provides a + * {@link Resource} for the given request, it will be it will be exposed using a + * {@link HandlerFunction} that handles GET, HEAD, and OPTIONS requests. + * @param lookupFunction the function to provide a {@link Resource} given the {@link ServerRequest} + * @return this builder + */ + Builder resources(Function> lookupFunction); + + /** + * Route to the supplied router function if the given request predicate applies. This method + * can be used to create nested routes, where a group of routes share a + * common path (prefix), header, or other request predicate. + *

For instance, the following example creates a nested route with a "/user" path + * predicate, so that GET requests for "/user" will list users, + * and POST request for "/user" will create a new user. + *

+		 * RouterFunction<ServerResponse> nestedRoute =
+		 *   RouterFunctions.route()
+		 *     .nest(RequestPredicates.path("/user"), () ->
+		 *       RouterFunctions.route()
+		 *         .GET(this::listUsers)
+		 *         .POST(this::createUser)
+		 *         .build())
+		 *     .build();
+		 * 
+ * @param predicate the predicate to test + * @param routerFunctionSupplier supplier for the nested router function to delegate to if + * the predicate applies + * @return this builder + * @see RequestPredicates + */ + Builder nest(RequestPredicate predicate, Supplier> routerFunctionSupplier); + + /** + * Route to a built router function if the given request predicate applies. + * This method can be used to create nested routes, where a group of routes + * share a common path (prefix), header, or other request predicate. + *

For instance, the following example creates a nested route with a "/user" path + * predicate, so that GET requests for "/user" will list users, + * and POST request for "/user" will create a new user. + *

+		 * RouterFunction<ServerResponse> nestedRoute =
+		 *   RouterFunctions.route()
+		 *     .nest(RequestPredicates.path("/user"), builder ->
+		 *       builder.GET(this::listUsers)
+		 *              .POST(this::createUser))
+		 *     .build();
+		 * 
+ * @param predicate the predicate to test + * @param builderConsumer consumer for a {@code Builder} that provides the nested router + * function + * @return this builder + * @see RequestPredicates + */ + Builder nest(RequestPredicate predicate, Consumer builderConsumer); + + /** + * Route to the supplied router function if the given path prefix pattern applies. This method + * can be used to create nested routes, where a group of routes share a + * common path prefix. Specifically, this method can be used to merge externally defined + * router functions under a path prefix. + *

For instance, the following example creates a nested route with a "/user" path + * predicate that delegates to the router function defined in {@code userController}, + * and with a "/order" path that delegates to {@code orderController}. + *

+		 * RouterFunction<ServerResponse> nestedRoute =
+		 *   RouterFunctions.route()
+		 *     .path("/user", userController::routerFunction)
+		 *     .path("/order", orderController::routerFunction)
+		 *     .build();
+		 * 
+ * @param pattern the pattern to match to + * @param routerFunctionSupplier supplier for the nested router function to delegate to if + * the pattern matches + * @return this builder + */ + Builder path(String pattern, Supplier> routerFunctionSupplier); + + /** + * Route to a built router function if the given path prefix pattern applies. + * This method can be used to create nested routes, where a group of routes + * share a common path prefix. + *

For instance, the following example creates a nested route with a "/user" path + * predicate, so that GET requests for "/user" will list users, + * and POST request for "/user" will create a new user. + *

+		 * RouterFunction<ServerResponse> nestedRoute =
+		 *   RouterFunctions.route()
+		 *     .path("/user", builder ->
+		 *       builder.GET(this::listUsers)
+		 *              .POST(this::createUser))
+		 *     .build();
+		 * 
+ * @param pattern the pattern to match to + * @param builderConsumer consumer for a {@code Builder} that provides the nested router + * function + * @return this builder + */ + Builder path(String pattern, Consumer builderConsumer); + + /** + * Filters all routes created by this builder with the given filter function. Filter + * functions are typically used to address cross-cutting concerns, such as logging, + * security, etc. + *

For instance, the following example creates a filter that returns a 401 Unauthorized + * response if the request does not contain the necessary authentication headers. + *

+		 * RouterFunction<ServerResponse> filteredRoute =
+		 *   RouterFunctions.route()
+		 *     .GET("/user", this::listUsers)
+		 *     .filter((request, next) -> {
+		 *       // check for authentication headers
+		 *       if (isAuthenticated(request)) {
+		 *         return next.handle(request);
+		 *       }
+		 *       else {
+		 *         return ServerResponse.status(HttpStatus.UNAUTHORIZED).build();
+		 *       }
+		 *     })
+		 *     .build();
+		 * 
+ * @param filterFunction the function to filter all routes built by this builder + * @return this builder + */ + Builder filter(HandlerFilterFunction filterFunction); + + /** + * Filter the request object for all routes created by this builder with the given request + * processing function. Filters are typically used to address cross-cutting concerns, such + * as logging, security, etc. + *

For instance, the following example creates a filter that logs the request before + * the handler function executes. + *

+		 * RouterFunction<ServerResponse> filteredRoute =
+		 *   RouterFunctions.route()
+		 *     .GET("/user", this::listUsers)
+		 *     .before(request -> {
+		 *       log(request);
+		 *       return request;
+		 *     })
+		 *     .build();
+		 * 
+ * @param requestProcessor a function that transforms the request + * @return this builder + */ + Builder before(Function requestProcessor); + + /** + * Filter the response object for all routes created by this builder with the given response + * processing function. Filters are typically used to address cross-cutting concerns, such + * as logging, security, etc. + *

For instance, the following example creates a filter that logs the response after + * the handler function executes. + *

+		 * RouterFunction<ServerResponse> filteredRoute =
+		 *   RouterFunctions.route()
+		 *     .GET("/user", this::listUsers)
+		 *     .after((request, response) -> {
+		 *       log(response);
+		 *       return response;
+		 *     })
+		 *     .build();
+		 * 
+ * @param responseProcessor a function that transforms the response + * @return this builder + */ + Builder after(BiFunction responseProcessor); + + /** + * Filters all exceptions that match the predicate by applying the given response provider + * function. + *

For instance, the following example creates a filter that returns a 500 response + * status when an {@code IllegalStateException} occurs. + *

+		 * RouterFunction<ServerResponse> filteredRoute =
+		 *   RouterFunctions.route()
+		 *     .GET("/user", this::listUsers)
+		 *     .onError(e -> e instanceof IllegalStateException,
+		 *       (e, request) -> ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build())
+		 *     .build();
+		 * 
+ * @param predicate the type of exception to filter + * @param responseProvider a function that creates a response + * @return this builder + */ + Builder onError(Predicate predicate, + BiFunction responseProvider); + + /** + * Filters all exceptions of the given type by applying the given response provider + * function. + *

For instance, the following example creates a filter that returns a 500 response + * status when an {@code IllegalStateException} occurs. + *

+		 * RouterFunction<ServerResponse> filteredRoute =
+		 *   RouterFunctions.route()
+		 *     .GET("/user", this::listUsers)
+		 *     .onError(IllegalStateException.class,
+		 *       (e, request) -> ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build())
+		 *     .build();
+		 * 
+ * @param exceptionType the type of exception to filter + * @param responseProvider a function that creates a response + * @return this builder + */ + Builder onError(Class exceptionType, + BiFunction responseProvider); + + /** + * Builds the {@code RouterFunction}. All created routes are + * {@linkplain RouterFunction#and(RouterFunction) composed} with one another, and filters + * (if any) are applied to the result. + * @return the built router function + */ + RouterFunction build(); + } + /** + * Receives notifications from the logical structure of router functions. + */ + public interface Visitor { + + /** + * Receive notification of the beginning of a nested router function. + * @param predicate the predicate that applies to the nested router functions + * @see RouterFunctions#nest(RequestPredicate, RouterFunction) + */ + void startNested(RequestPredicate predicate); + + /** + * Receive notification of the end of a nested router function. + * @param predicate the predicate that applies to the nested router functions + * @see RouterFunctions#nest(RequestPredicate, RouterFunction) + */ + void endNested(RequestPredicate predicate); + + /** + * Receive notification of a standard predicated route to a handler function. + * @param predicate the predicate that applies to the handler function + * @param handlerFunction the handler function. + * @see RouterFunctions#route(RequestPredicate, HandlerFunction) + */ + void route(RequestPredicate predicate, HandlerFunction handlerFunction); + + /** + * Receive notification of a resource router function. + * @param lookupFunction the lookup function for the resources + * @see RouterFunctions#resources(Function) + */ + void resources(Function> lookupFunction); + + /** + * Receive notification of an unknown router function. This method is called for router + * functions that were not created via the various {@link RouterFunctions} methods. + * @param routerFunction the router function + */ + void unknown(RouterFunction routerFunction); + } + + + private abstract static class AbstractRouterFunction implements RouterFunction { + + @Override + public String toString() { + ToStringVisitor visitor = new ToStringVisitor(); + accept(visitor); + return visitor.toString(); + } + } + + /** + * A composed routing function that first invokes one function, and then invokes the + * another function (of the same response type {@code T}) if this route had + * {@linkplain Optional#empty() no result}. + * @param the server response type + */ + static final class SameComposedRouterFunction extends AbstractRouterFunction { + + private final RouterFunction first; + + private final RouterFunction second; + + public SameComposedRouterFunction(RouterFunction first, RouterFunction second) { + this.first = first; + this.second = second; + } + + @Override + public Optional> route(ServerRequest request) { + Optional> firstRoute = this.first.route(request); + if (firstRoute.isPresent()) { + return firstRoute; + } + else { + return this.second.route(request); + } + } + + @Override + public void accept(Visitor visitor) { + this.first.accept(visitor); + this.second.accept(visitor); + } + } + + /** + * A composed routing function that first invokes one function, and then invokes + * another function (of a different response type) if this route had + * {@linkplain Optional#empty() no result}. + */ + static final class DifferentComposedRouterFunction extends AbstractRouterFunction { + + private final RouterFunction first; + + private final RouterFunction second; + + public DifferentComposedRouterFunction(RouterFunction first, RouterFunction second) { + this.first = first; + this.second = second; + } + + @Override + @SuppressWarnings("unchecked") + public Optional> route(ServerRequest request) { + Optional> firstRoute = this.first.route(request); + if (firstRoute.isPresent()) { + return (Optional>) firstRoute; + } + else { + Optional> secondRoute = this.second.route(request); + return (Optional>) secondRoute; + } + } + + @Override + public void accept(Visitor visitor) { + this.first.accept(visitor); + this.second.accept(visitor); + } + } + + + /** + * Filter the specified {@linkplain HandlerFunction handler functions} with the given + * {@linkplain HandlerFilterFunction filter function}. + * @param the type of the {@linkplain HandlerFunction handler function} to filter + * @param the type of the response of the function + */ + static final class FilteredRouterFunction + implements RouterFunction { + + private final RouterFunction routerFunction; + + private final HandlerFilterFunction filterFunction; + + public FilteredRouterFunction( + RouterFunction routerFunction, + HandlerFilterFunction filterFunction) { + this.routerFunction = routerFunction; + this.filterFunction = filterFunction; + } + + @Override + public Optional> route(ServerRequest request) { + return this.routerFunction.route(request).map(this.filterFunction::apply); + } + + @Override + public void accept(Visitor visitor) { + this.routerFunction.accept(visitor); + } + + @Override + public String toString() { + return this.routerFunction.toString(); + } + } + + private static final class DefaultRouterFunction + extends AbstractRouterFunction { + + private final RequestPredicate predicate; + + private final HandlerFunction handlerFunction; + + public DefaultRouterFunction(RequestPredicate predicate, HandlerFunction handlerFunction) { + Assert.notNull(predicate, "Predicate must not be null"); + Assert.notNull(handlerFunction, "HandlerFunction must not be null"); + this.predicate = predicate; + this.handlerFunction = handlerFunction; + } + + @Override + public Optional> route(ServerRequest request) { + if (this.predicate.test(request)) { + if (logger.isTraceEnabled()) { + logger.trace(String.format("Predicate \"%s\" matches against \"%s\"", this.predicate, request)); + } + return Optional.of(this.handlerFunction); + } + else { + return Optional.empty(); + } + } + + @Override + public void accept(Visitor visitor) { + visitor.route(this.predicate, this.handlerFunction); + } + + } + + private static final class DefaultNestedRouterFunction + extends AbstractRouterFunction { + + private final RequestPredicate predicate; + + private final RouterFunction routerFunction; + + public DefaultNestedRouterFunction(RequestPredicate predicate, RouterFunction routerFunction) { + Assert.notNull(predicate, "Predicate must not be null"); + Assert.notNull(routerFunction, "RouterFunction must not be null"); + this.predicate = predicate; + this.routerFunction = routerFunction; + } + + @Override + public Optional> route(ServerRequest serverRequest) { + return this.predicate.nest(serverRequest) + .map(nestedRequest -> { + if (logger.isTraceEnabled()) { + logger.trace( + String.format( + "Nested predicate \"%s\" matches against \"%s\"", + this.predicate, serverRequest)); + } + Optional> result = this.routerFunction.route(nestedRequest); + if (result.isPresent() && nestedRequest != serverRequest) { + serverRequest.attributes().clear(); + serverRequest.attributes().putAll(nestedRequest.attributes()); + } + return result; + } + ) + .orElseGet(Optional::empty); + } + + + @Override + public void accept(Visitor visitor) { + visitor.startNested(this.predicate); + this.routerFunction.accept(visitor); + visitor.endNested(this.predicate); + } + + } + + private static class ResourcesRouterFunction extends AbstractRouterFunction { + + private final Function> lookupFunction; + + public ResourcesRouterFunction(Function> lookupFunction) { + Assert.notNull(lookupFunction, "Function must not be null"); + this.lookupFunction = lookupFunction; + } + + @Override + public Optional> route(ServerRequest request) { + return this.lookupFunction.apply(request).map(ResourceHandlerFunction::new); + } + + @Override + public void accept(Visitor visitor) { + visitor.resources(this.lookupFunction); + } + } + + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java new file mode 100644 index 0000000000..247b1d8a51 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java @@ -0,0 +1,416 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.Charset; +import java.security.Principal; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.function.Consumer; +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpRange; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.PathContainer; +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriBuilder; + +/** + * Represents a server-side HTTP request, as handled by a {@code HandlerFunction}. + * Access to headers and body is offered by {@link Headers} and + * {@link #body(Class)}, respectively. + * + * @author Arjen Poutsma + * @since 5.2 + */ +public interface ServerRequest { + + /** + * Get the HTTP method. + * @return the HTTP method as an HttpMethod enum value, or {@code null} + * if not resolvable (e.g. in case of a non-standard HTTP method) + */ + @Nullable + default HttpMethod method() { + return HttpMethod.resolve(methodName()); + } + + /** + * Get the name of the HTTP method. + * @return the HTTP method as a String + */ + String methodName(); + + /** + * Get the request URI. + */ + URI uri(); + + /** + * Get a {@code UriBuilderComponents} from the URI associated with this + * {@code ServerRequest}. + * + * @return a URI builder + */ + UriBuilder uriBuilder(); + + /** + * Get the request path. + */ + default String path() { + return uri().getRawPath(); + } + + /** + * Get the request path as a {@code PathContainer}. + */ + default PathContainer pathContainer() { + return PathContainer.parsePath(path()); + } + + /** + * Get the headers of this request. + */ + Headers headers(); + + /** + * Get the cookies of this request. + */ + MultiValueMap cookies(); + + /** + * Get the remote address to which this request is connected, if available. + */ + Optional remoteAddress(); + + /** + * Get the readers used to convert the body of this request. + */ + List> messageConverters(); + + /** + * Extract the body as an object of the given type. + * @param bodyType the type of return value + * @param the body type + * @return the body + */ + T body(Class bodyType) throws ServletException, IOException; + + /** + * Extract the body as an object of the given type. + * @param bodyType the type of return value + * @param the body type + * @return the body + */ + T body(ParameterizedTypeReference bodyType) throws ServletException, IOException; + + /** + * Get the request attribute value if present. + * @param name the attribute name + * @return the attribute value + */ + default Optional attribute(String name) { + Map attributes = attributes(); + if (attributes.containsKey(name)) { + return Optional.of(attributes.get(name)); + } + else { + return Optional.empty(); + } + } + + /** + * Get a mutable map of request attributes. + * @return the request attributes + */ + Map attributes(); + + /** + * Get the first query parameter with the given name, if present. + * @param name the parameter name + * @return the parameter value + */ + default Optional param(String name) { + List paramValues = params().get(name); + if (CollectionUtils.isEmpty(paramValues)) { + return Optional.empty(); + } + else { + String value = paramValues.get(0); + if (value == null) { + value = ""; + } + return Optional.of(value); + } + } + + /** + * Get all query parameters for this request. + */ + MultiValueMap params(); + + /** + * Get the path variable with the given name, if present. + * @param name the variable name + * @return the variable value + * @throws IllegalArgumentException if there is no path variable with the given name + */ + default String pathVariable(String name) { + Map pathVariables = pathVariables(); + if (pathVariables.containsKey(name)) { + return pathVariables().get(name); + } + else { + throw new IllegalArgumentException("No path variable with name \"" + name + "\" available"); + } + } + + /** + * Get all path variables for this request. + */ + Map pathVariables(); + + /** + * Get the web session for this request. Always guaranteed to + * return an instance either matching to the session id requested by the + * client, or with a new session id either because the client did not + * specify one or because the underlying session had expired. Use of this + * method does not automatically create a session. + */ + HttpSession session(); + + /** + * Get the authenticated user for the request, if any. + */ + Optional principal(); + + /** + * Get the servlet request that this request is based on. + */ + HttpServletRequest servletRequest(); + + + // Static methods + + /** + * Create a new {@code ServerRequest} based on the given {@code {@link HttpServletRequest} and + * message converters. + * @param servletRequest the request + * @param messageReaders the message readers + * @return the created {@code ServerRequest} + */ + static ServerRequest create(HttpServletRequest servletRequest, List> messageReaders) { + return new DefaultServerRequest(servletRequest, messageReaders); + } + + /** + * Create a builder with the status, headers, and cookies of the given request. + * @param other the response to copy the status, headers, and cookies from + * @return the created builder + */ + static Builder from(ServerRequest other) { + return new DefaultServerRequestBuilder(other); + } + + + /** + * Represents the headers of the HTTP request. + * @see ServerRequest#headers() + */ + interface Headers { + + /** + * Get the list of acceptable media types, as specified by the {@code Accept} + * header. + *

Returns an empty list if the acceptable media types are unspecified. + */ + List accept(); + + /** + * Get the list of acceptable charsets, as specified by the + * {@code Accept-Charset} header. + */ + List acceptCharset(); + + /** + * Get the list of acceptable languages, as specified by the + * {@code Accept-Language} header. + */ + List acceptLanguage(); + + /** + * Get the length of the body in bytes, as specified by the + * {@code Content-Length} header. + */ + OptionalLong contentLength(); + + /** + * Get the media type of the body, as specified by the + * {@code Content-Type} header. + */ + Optional contentType(); + + /** + * Get the value of the {@code Host} header, if available. + *

If the header value does not contain a port, the + * {@linkplain InetSocketAddress#getPort() port} in the returned address will + * be {@code 0}. + */ + @Nullable + InetSocketAddress host(); + + /** + * Get the value of the {@code Range} header. + *

Returns an empty list when the range is unknown. + */ + List range(); + + /** + * Get the header value(s), if any, for the header of the given name. + *

Returns an empty list if no header values are found. + * @param headerName the header name + */ + List header(String headerName); + + /** + * Get the headers as an instance of {@link HttpHeaders}. + */ + HttpHeaders asHttpHeaders(); + } + + + /** + * Defines a builder for a request. + */ + interface Builder { + + /** + * Set the method of the request. + * @param method the new method + * @return this builder + */ + Builder method(HttpMethod method); + + /** + * Set the URI of the request. + * @param uri the new URI + * @return this builder + */ + Builder uri(URI uri); + + /** + * Add the given header value(s) under the given name. + * @param headerName the header name + * @param headerValues the header value(s) + * @return this builder + * @see HttpHeaders#add(String, String) + */ + Builder header(String headerName, String... headerValues); + + /** + * Manipulate this request's headers with the given consumer. + *

The headers provided to the consumer are "live", so that the consumer can be used to + * {@linkplain HttpHeaders#set(String, String) overwrite} existing header values, + * {@linkplain HttpHeaders#remove(Object) remove} values, or use any of the other + * {@link HttpHeaders} methods. + * @param headersConsumer a function that consumes the {@code HttpHeaders} + * @return this builder + */ + Builder headers(Consumer headersConsumer); + + /** + * Add a cookie with the given name and value(s). + * @param name the cookie name + * @param values the cookie value(s) + * @return this builder + */ + Builder cookie(String name, String... values); + + /** + * Manipulate this request's cookies with the given consumer. + *

The map provided to the consumer is "live", so that the consumer can be used to + * {@linkplain MultiValueMap#set(Object, Object) overwrite} existing cookies, + * {@linkplain MultiValueMap#remove(Object) remove} cookies, or use any of the other + * {@link MultiValueMap} methods. + * @param cookiesConsumer a function that consumes the cookies map + * @return this builder + */ + Builder cookies(Consumer> cookiesConsumer); + + /** + * Set the body of the request. + *

Calling this methods will + * {@linkplain org.springframework.core.io.buffer.DataBufferUtils#release(DataBuffer) release} + * the existing body of the builder. + * @param body the new body + * @return this builder + */ + Builder body(byte[] body); + + /** + * Set the body of the request to the UTF-8 encoded bytes of the given string. + *

Calling this methods will + * {@linkplain org.springframework.core.io.buffer.DataBufferUtils#release(DataBuffer) release} + * the existing body of the builder. + * @param body the new body + * @return this builder + */ + Builder body(String body); + + /** + * Add an attribute with the given name and value. + * @param name the attribute name + * @param value the attribute value + * @return this builder + */ + Builder attribute(String name, Object value); + + /** + * Manipulate this request's attributes with the given consumer. + *

The map provided to the consumer is "live", so that the consumer can be used + * to {@linkplain Map#put(Object, Object) overwrite} existing attributes, + * {@linkplain Map#remove(Object) remove} attributes, or use any of the other + * {@link Map} methods. + * @param attributesConsumer a function that consumes the attributes map + * @return this builder + */ + Builder attributes(Consumer> attributesConsumer); + + /** + * Build the request. + * @return the built request + */ + ServerRequest build(); + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java new file mode 100644 index 0000000000..20a5cd8bf7 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java @@ -0,0 +1,422 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.io.IOException; +import java.net.URI; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletionStage; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.reactivestreams.Publisher; + +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.util.MultiValueMap; +import org.springframework.web.servlet.ModelAndView; + +/** + * Represents a typed server-side HTTP response, as returned + * by a {@linkplain HandlerFunction handler function} or + * {@linkplain HandlerFilterFunction filter function}. + * + * @author Arjen Poutsma + * @since 5.2 + */ +public interface ServerResponse { + + /** + * Return the status code of this response. + */ + HttpStatus statusCode(); + + /** + * Return the headers of this response. + */ + HttpHeaders headers(); + + /** + * Return the cookies of this response. + */ + MultiValueMap cookies(); + + /** + * Write this response to the servlet response (and return {@code null}, or return a + * @param request the current request + * @param response the response to write to + * @param context the context to use when writing + * @return {@code Mono} to indicate when writing is complete + */ + @Nullable + ModelAndView writeTo(HttpServletRequest request, HttpServletResponse response, Context context) + throws ServletException, IOException; + + + // Static methods + + /** + * Create a builder with the status code and headers of the given response. + * @param other the response to copy the status and headers from + * @return the created builder + */ + static BodyBuilder from(ServerResponse other) { + return new DefaultServerResponseBuilder(other); + } + + /** + * Create a builder with the given HTTP status. + * @param status the response status + * @return the created builder + */ + static BodyBuilder status(HttpStatus status) { + return new DefaultServerResponseBuilder(status); + } + + /** + * Create a builder with the given HTTP status. + * @param status the response status + * @return the created builder + */ + static BodyBuilder status(int status) { + return new DefaultServerResponseBuilder(status); + } + + /** + * Create a builder with the status set to {@linkplain HttpStatus#OK 200 OK}. + * @return the created builder + */ + static BodyBuilder ok() { + return status(HttpStatus.OK); + } + + /** + * Create a new builder with a {@linkplain HttpStatus#CREATED 201 Created} status + * and a location header set to the given URI. + * @param location the location URI + * @return the created builder + */ + static BodyBuilder created(URI location) { + BodyBuilder builder = status(HttpStatus.CREATED); + return builder.location(location); + } + + /** + * Create a builder with an {@linkplain HttpStatus#ACCEPTED 202 Accepted} status. + * @return the created builder + */ + static BodyBuilder accepted() { + return status(HttpStatus.ACCEPTED); + } + + /** + * Create a builder with a {@linkplain HttpStatus#NO_CONTENT 204 No Content} status. + * @return the created builder + */ + static HeadersBuilder noContent() { + return status(HttpStatus.NO_CONTENT); + } + + /** + * Create a builder with a {@linkplain HttpStatus#SEE_OTHER 303 See Other} + * status and a location header set to the given URI. + * @param location the location URI + * @return the created builder + */ + static BodyBuilder seeOther(URI location) { + BodyBuilder builder = status(HttpStatus.SEE_OTHER); + return builder.location(location); + } + + /** + * Create a builder with a {@linkplain HttpStatus#TEMPORARY_REDIRECT 307 Temporary Redirect} + * status and a location header set to the given URI. + * @param location the location URI + * @return the created builder + */ + static BodyBuilder temporaryRedirect(URI location) { + BodyBuilder builder = status(HttpStatus.TEMPORARY_REDIRECT); + return builder.location(location); + } + + /** + * Create a builder with a {@linkplain HttpStatus#PERMANENT_REDIRECT 308 Permanent Redirect} + * status and a location header set to the given URI. + * @param location the location URI + * @return the created builder + */ + static BodyBuilder permanentRedirect(URI location) { + BodyBuilder builder = status(HttpStatus.PERMANENT_REDIRECT); + return builder.location(location); + } + + /** + * Create a builder with a {@linkplain HttpStatus#BAD_REQUEST 400 Bad Request} status. + * @return the created builder + */ + static BodyBuilder badRequest() { + return status(HttpStatus.BAD_REQUEST); + } + + /** + * Create a builder with a {@linkplain HttpStatus#NOT_FOUND 404 Not Found} status. + * @return the created builder + */ + static HeadersBuilder notFound() { + return status(HttpStatus.NOT_FOUND); + } + + /** + * Create a builder with an + * {@linkplain HttpStatus#UNPROCESSABLE_ENTITY 422 Unprocessable Entity} status. + * @return the created builder + */ + static BodyBuilder unprocessableEntity() { + return status(HttpStatus.UNPROCESSABLE_ENTITY); + } + + + /** + * Defines a builder that adds headers to the response. + * @param the builder subclass + */ + interface HeadersBuilder> { + + /** + * Add the given header value(s) under the given name. + * @param headerName the header name + * @param headerValues the header value(s) + * @return this builder + * @see HttpHeaders#add(String, String) + */ + B header(String headerName, String... headerValues); + + /** + * Manipulate this response's headers with the given consumer. The + * headers provided to the consumer are "live", so that the consumer can be used to + * {@linkplain HttpHeaders#set(String, String) overwrite} existing header values, + * {@linkplain HttpHeaders#remove(Object) remove} values, or use any of the other + * {@link HttpHeaders} methods. + * @param headersConsumer a function that consumes the {@code HttpHeaders} + * @return this builder + */ + B headers(Consumer headersConsumer); + + /** + * Add the given cookie to the response. + * @param cookie the cookie to add + * @return this builder + */ + B cookie(Cookie cookie); + + /** + * Manipulate this response's cookies with the given consumer. The + * cookies provided to the consumer are "live", so that the consumer can be used to + * {@linkplain MultiValueMap#set(Object, Object) overwrite} existing cookies, + * {@linkplain MultiValueMap#remove(Object) remove} cookies, or use any of the other + * {@link MultiValueMap} methods. + * @param cookiesConsumer a function that consumes the cookies + * @return this builder + */ + B cookies(Consumer> cookiesConsumer); + + /** + * Set the set of allowed {@link HttpMethod HTTP methods}, as specified + * by the {@code Allow} header. + * + * @param allowedMethods the allowed methods + * @return this builder + * @see HttpHeaders#setAllow(Set) + */ + B allow(HttpMethod... allowedMethods); + + /** + * Set the set of allowed {@link HttpMethod HTTP methods}, as specified + * by the {@code Allow} header. + * @param allowedMethods the allowed methods + * @return this builder + * @see HttpHeaders#setAllow(Set) + */ + B allow(Set allowedMethods); + + /** + * Set the entity tag of the body, as specified by the {@code ETag} header. + * @param eTag the new entity tag + * @return this builder + * @see HttpHeaders#setETag(String) + */ + B eTag(String eTag); + + /** + * Set the time the resource was last changed, as specified by the + * {@code Last-Modified} header. + * @param lastModified the last modified date + * @return this builder + * @see HttpHeaders#setLastModified(long) + */ + B lastModified(ZonedDateTime lastModified); + + /** + * Set the time the resource was last changed, as specified by the + * {@code Last-Modified} header. + * @param lastModified the last modified date + * @return this builder + * @see HttpHeaders#setLastModified(long) + */ + B lastModified(Instant lastModified); + /** + * Set the location of a resource, as specified by the {@code Location} header. + * @param location the location + * @return this builder + * @see HttpHeaders#setLocation(URI) + */ + B location(URI location); + + /** + * Set the caching directives for the resource, as specified by the HTTP 1.1 + * {@code Cache-Control} header. + *

A {@code CacheControl} instance can be built like + * {@code CacheControl.maxAge(3600).cachePublic().noTransform()}. + * @param cacheControl a builder for cache-related HTTP response headers + * @return this builder + * @see RFC-7234 Section 5.2 + */ + B cacheControl(CacheControl cacheControl); + + /** + * Configure one or more request header names (e.g. "Accept-Language") to + * add to the "Vary" response header to inform clients that the response is + * subject to content negotiation and variances based on the value of the + * given request headers. The configured request header names are added only + * if not already present in the response "Vary" header. + * @param requestHeaders request header names + * @return this builder + */ + B varyBy(String... requestHeaders); + + /** + * Build the response entity with no body. + */ + ServerResponse build(); + + /** + * Build the response entity with a custom write function. + * @param writeFunction the function used to write to the {@link HttpServletResponse} + */ + ServerResponse build(BiFunction writeFunction); + + } + + + /** + * Defines a builder that adds a body to the response. + */ + interface BodyBuilder extends HeadersBuilder { + + /** + * Set the length of the body in bytes, as specified by the + * {@code Content-Length} header. + * @param contentLength the content length + * @return this builder + * @see HttpHeaders#setContentLength(long) + */ + BodyBuilder contentLength(long contentLength); + + /** + * Set the {@linkplain MediaType media type} of the body, as specified by the + * {@code Content-Type} header. + * @param contentType the content type + * @return this builder + * @see HttpHeaders#setContentType(MediaType) + */ + BodyBuilder contentType(MediaType contentType); + + /** + * Set the body of the response to the given {@code Object} and return it. + * @param body the body of the response + * @return the built response + */ + ServerResponse body(Object body); + + /** + * Set the asynchronous body of the response to the given {@link CompletionStage} and + * return it. + * @param asyncBody the body of the response + * @return the built response + */ + ServerResponse asyncBody(CompletionStage asyncBody); + + /** + * Set the asynchronous body of the response to the given {@link Publisher} and + * return it. + * @param asyncBody the body of the response + * @return the built response + */ + ServerResponse asyncBody(Publisher asyncBody); + + /** + * Render the template with the given {@code name} using the given {@code modelAttributes}. + * The model attributes are mapped under a + * {@linkplain org.springframework.core.Conventions#getVariableName generated name}. + *

Note: Empty {@link Collection Collections} are not added to + * the model when using this method because we cannot correctly determine + * the true convention name. + * @param name the name of the template to be rendered + * @param modelAttributes the modelAttributes used to render the template + * @return the built response + */ + ServerResponse render(String name, Object... modelAttributes); + + /** + * Render the template with the given {@code name} using the given {@code model}. + * @param name the name of the template to be rendered + * @param model the model used to render the template + * @return the built response + */ + ServerResponse render(String name, Map model); + } + + + /** + * Defines the context used during the {@link #writeTo(HttpServletRequest, HttpServletResponse, Context)}. + */ + interface Context { + + /** + * Return the {@link HttpMessageConverter HttpMessageConverters} to be used for response body conversion. + * @return the list of message writers + */ + List> messageConverters(); + + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ToStringVisitor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ToStringVisitor.java new file mode 100644 index 0000000000..f65ad48030 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ToStringVisitor.java @@ -0,0 +1,167 @@ +/* + * Copyright 2002-2019 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.servlet.function; + +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +import org.springframework.core.io.Resource; +import org.springframework.http.HttpMethod; + +/** + * Implementation of {@link RouterFunctions.Visitor} that creates a formatted + * string representation of router functions. + * + * @author Arjen Poutsma + * @since 5.2 + */ +class ToStringVisitor implements RouterFunctions.Visitor, RequestPredicates.Visitor { + + private final StringBuilder builder = new StringBuilder(); + + private int indent = 0; + + + // RouterFunctions.Visitor + @Override + public void startNested(RequestPredicate predicate) { + indent(); + predicate.accept(this); + this.builder.append(" => {\n"); + this.indent++; + } + + @Override + public void endNested(RequestPredicate predicate) { + this.indent--; + indent(); + this.builder.append("}\n"); + } + + @Override + public void route(RequestPredicate predicate, HandlerFunction handlerFunction) { + indent(); + predicate.accept(this); + this.builder.append(" -> "); + this.builder.append(handlerFunction).append('\n'); + } + + @Override + public void resources(Function> lookupFunction) { + indent(); + this.builder.append(lookupFunction).append('\n'); + } + + @Override + public void unknown(RouterFunction routerFunction) { + indent(); + this.builder.append(routerFunction); + } + + private void indent() { + for (int i=0; i < this.indent; i++) { + this.builder.append(' '); + } + } + + // RequestPredicates.Visitor + + @Override + public void method(Set methods) { + if (methods.size() == 1) { + this.builder.append(methods.iterator().next()); + } + else { + this.builder.append(methods); + } + } + + @Override + public void path(String pattern) { + this.builder.append(pattern); + } + + @Override + public void pathExtension(String extension) { + this.builder.append(String.format("*.%s", extension)); + } + + @Override + public void header(String name, String value) { + this.builder.append(String.format("%s: %s", name, value)); + } + + @Override + public void param(String name, String value) { + this.builder.append(String.format("?%s == %s", name, value)); + } + + @Override + public void startAnd() { + this.builder.append('('); + } + + @Override + public void and() { + this.builder.append(" && "); + } + + @Override + public void endAnd() { + this.builder.append(')'); + } + + @Override + public void startOr() { + this.builder.append('('); + } + + @Override + public void or() { + this.builder.append(" || "); + + } + + @Override + public void endOr() { + this.builder.append(')'); + } + + @Override + public void startNegate() { + this.builder.append("!("); + } + + @Override + public void endNegate() { + this.builder.append(')'); + } + + @Override + public void unknown(RequestPredicate predicate) { + this.builder.append(predicate); + } + @Override + public String toString() { + String result = this.builder.toString(); + if (result.endsWith("\n")) { + result = result.substring(0, result.length() - 1); + } + return result; + } +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/package-info.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/package-info.java new file mode 100644 index 0000000000..f50375d311 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/package-info.java @@ -0,0 +1,9 @@ +/** + * Provides the types that make up Spring's functional web framework for Servlet environments. + */ +@NonNullApi +@NonNullFields +package org.springframework.web.servlet.function; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields;