diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultRequestPath.java b/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultRequestPath.java new file mode 100644 index 0000000000..38d02e7d0b --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultRequestPath.java @@ -0,0 +1,318 @@ +/* + * Copyright 2002-2017 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.http.server.reactive; + +import java.net.URI; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +class DefaultRequestPath implements RequestPath { + + private static final MultiValueMap EMPTY_MAP = new LinkedMultiValueMap<>(0); + + private static final PathSegment EMPTY_PATH_SEGMENT = new DefaultPathSegment("", "", "", EMPTY_MAP); + + private static final PathSegmentContainer EMPTY_PATH = + new DefaultPathSegmentContainer("", Collections.emptyList()); + + private static final PathSegmentContainer ROOT_PATH = + new DefaultPathSegmentContainer("/", Collections.singletonList(EMPTY_PATH_SEGMENT)); + + + private final PathSegmentContainer fullPath; + + private final PathSegmentContainer contextPath; + + private final PathSegmentContainer pathWithinApplication; + + + DefaultRequestPath(URI uri, String contextPath, Charset charset) { + this.fullPath = parsePath(uri.getRawPath(), charset); + this.contextPath = initContextPath(this.fullPath, contextPath); + this.pathWithinApplication = initPathWithinApplication(this.fullPath, this.contextPath); + } + + + private static PathSegmentContainer parsePath(String path, Charset charset) { + path = StringUtils.hasText(path) ? path : ""; + if ("".equals(path)) { + return EMPTY_PATH; + } + if ("/".equals(path)) { + return ROOT_PATH; + } + List result = new ArrayList<>(); + int begin = 1; + while (true) { + int end = path.indexOf('/', begin); + String segment = (end != -1 ? path.substring(begin, end) : path.substring(begin)); + result.add(parsePathSegment(segment, charset)); + if (end == -1) { + break; + } + begin = end + 1; + if (begin == path.length()) { + // trailing slash + result.add(EMPTY_PATH_SEGMENT); + break; + } + } + return new DefaultPathSegmentContainer(path, result); + } + + private static PathSegment parsePathSegment(String input, Charset charset) { + if ("".equals(input)) { + return EMPTY_PATH_SEGMENT; + } + int index = input.indexOf(';'); + if (index == -1) { + return new DefaultPathSegment(input, StringUtils.uriDecode(input, charset), "", EMPTY_MAP); + } + String value = input.substring(0, index); + String valueDecoded = StringUtils.uriDecode(value, charset); + String semicolonContent = input.substring(index); + MultiValueMap parameters = parseParams(semicolonContent, charset); + return new DefaultPathSegment(value, valueDecoded, semicolonContent, parameters); + } + + private static MultiValueMap parseParams(String input, Charset charset) { + MultiValueMap result = new LinkedMultiValueMap<>(); + int begin = 1; + while (begin < input.length()) { + int end = input.indexOf(';', begin); + String param = (end != -1 ? input.substring(begin, end) : input.substring(begin)); + parseParamValues(param, charset, result); + if (end == -1) { + break; + } + begin = end + 1; + } + return result; + } + + private static void parseParamValues(String input, Charset charset, MultiValueMap output) { + if (StringUtils.hasText(input)) { + int index = input.indexOf("="); + if (index != -1) { + String name = input.substring(0, index); + String value = input.substring(index + 1); + for (String v : StringUtils.commaDelimitedListToStringArray(value)) { + name = StringUtils.uriDecode(name, charset); + if (StringUtils.hasText(name)) { + output.add(name, StringUtils.uriDecode(v, charset)); + } + } + } + else { + String name = StringUtils.uriDecode(input, charset); + if (StringUtils.hasText(name)) { + output.add(input, ""); + } + } + } + } + + private static PathSegmentContainer initContextPath(PathSegmentContainer path, String contextPath) { + if (!StringUtils.hasText(contextPath) || "/".equals(contextPath)) { + return EMPTY_PATH; + } + + Assert.isTrue(contextPath.startsWith("/") && !contextPath.endsWith("/") && + path.value().startsWith(contextPath), "Invalid contextPath: " + contextPath); + + int length = contextPath.length(); + int counter = 0; + + List result = new ArrayList<>(); + for (PathSegment pathSegment : path.pathSegments()) { + result.add(pathSegment); + counter += 1; // for '/' separators + counter += pathSegment.value().length(); + counter += pathSegment.semicolonContent().length(); + if (length == counter) { + return new DefaultPathSegmentContainer(contextPath, result); + } + } + + // Should not happen.. + throw new IllegalStateException("Failed to initialize contextPath='" + contextPath + "'" + + " given path='" + path.value() + "'"); + } + + private static PathSegmentContainer initPathWithinApplication(PathSegmentContainer path, + PathSegmentContainer contextPath) { + + String value = path.value().substring(contextPath.value().length()); + List pathSegments = new ArrayList<>(path.pathSegments()); + pathSegments.removeAll(contextPath.pathSegments()); + return new DefaultPathSegmentContainer(value, pathSegments); + } + + + @Override + public String value() { + return this.fullPath.value(); + } + + @Override + public List pathSegments() { + return this.fullPath.pathSegments(); + } + + @Override + public PathSegmentContainer contextPath() { + return this.contextPath; + } + + @Override + public PathSegmentContainer pathWithinApplication() { + return this.pathWithinApplication; + } + + + private static class DefaultPathSegmentContainer implements PathSegmentContainer { + + private final String path; + + private final List pathSegments; + + + DefaultPathSegmentContainer(String path, List pathSegments) { + this.path = path; + this.pathSegments = Collections.unmodifiableList(pathSegments); + } + + + @Override + public String value() { + return this.path; + } + + @Override + public List pathSegments() { + return this.pathSegments; + } + + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + return this.path.equals(((DefaultPathSegmentContainer) other).path); + } + + @Override + public int hashCode() { + return this.path.hashCode(); + } + + @Override + public String toString() { + return "[path='" + this.path + "\']"; + } + } + + + private static class DefaultPathSegment implements PathSegment { + + private final String value; + + private final String valueDecoded; + + private final String semicolonContent; + + private final MultiValueMap parameters; + + + DefaultPathSegment(String value, String valueDecoded, String semicolonContent, + MultiValueMap params) { + + this.value = value; + this.valueDecoded = valueDecoded; + this.semicolonContent = semicolonContent; + this.parameters = CollectionUtils.unmodifiableMultiValueMap(params); + } + + + @Override + public String value() { + return this.value; + } + + @Override + public String valueDecoded() { + return this.valueDecoded; + } + + @Override + public String semicolonContent() { + return this.semicolonContent; + } + + @Override + public MultiValueMap parameters() { + return this.parameters; + } + + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + + DefaultPathSegment segment = (DefaultPathSegment) other; + return (this.value.equals(segment.value) && + this.semicolonContent.equals(segment.semicolonContent) && + this.parameters.equals(segment.parameters)); + } + + @Override + public int hashCode() { + int result = this.value.hashCode(); + result = 31 * result + this.semicolonContent.hashCode(); + result = 31 * result + this.parameters.hashCode(); + return result; + } + + public String toString() { + return "[value='" + this.value + "\', " + + "semicolonContent='" + this.semicolonContent + "\', " + + "parameters=" + this.parameters + "']"; + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/PathSegment.java b/spring-web/src/main/java/org/springframework/http/server/reactive/PathSegment.java new file mode 100644 index 0000000000..5673a87c5c --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/PathSegment.java @@ -0,0 +1,51 @@ +/* + * Copyright 2002-2017 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.http.server.reactive; + +import org.springframework.util.MultiValueMap; + +/** + * Represents the content of one path segment. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public interface PathSegment { + + /** + * Return the original, raw (encoded) path segment value not including + * path parameters. + */ + String value(); + + /** + * The path {@link #value()} decoded. + */ + String valueDecoded(); + + /** + * Return the portion of the path segment after and including the first + * ";" (semicolon) representing path parameters. The actual parsed + * parameters if any can be obtained via {@link #parameters()}. + */ + String semicolonContent(); + + /** + * Path parameters parsed from the path segment. + */ + MultiValueMap parameters(); + +} diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/PathSegmentContainer.java b/spring-web/src/main/java/org/springframework/http/server/reactive/PathSegmentContainer.java new file mode 100644 index 0000000000..a77c731242 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/PathSegmentContainer.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2017 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.http.server.reactive; + +import java.util.List; + +/** + * Container for 0..N path segments. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public interface PathSegmentContainer { + + /** + * The original, raw (encoded) path value including path parameters. + */ + String value(); + + /** + * The list of path segments contained. + */ + List pathSegments(); + +} diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/RequestPath.java b/spring-web/src/main/java/org/springframework/http/server/reactive/RequestPath.java new file mode 100644 index 0000000000..5db4e87df7 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/RequestPath.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2017 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.http.server.reactive; + +/** + * Represents the complete path for a request. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public interface RequestPath extends PathSegmentContainer { + + /** + * The contextPath portion of the request if any. + */ + PathSegmentContainer contextPath(); + + /** + * The portion of the request path after the context path. + */ + PathSegmentContainer pathWithinApplication(); + +} diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/DefaultRequestPathTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/DefaultRequestPathTests.java new file mode 100644 index 0000000000..24edf8abd2 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/DefaultRequestPathTests.java @@ -0,0 +1,134 @@ +/* + * Copyright 2002-2017 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.http.server.reactive; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.Test; + +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import static org.junit.Assert.assertEquals; + +/** + * Unit tests for {@link DefaultRequestPath}. + * @author Rossen Stoyanchev + */ +public class DefaultRequestPathTests { + + @Test + public void pathSegment() throws Exception { + // basic + testPathSegment("cars", "", "cars", "cars", new LinkedMultiValueMap<>()); + + // empty + testPathSegment("", "", "", "", new LinkedMultiValueMap<>()); + + // spaces + testPathSegment("%20", "", "%20", " ", new LinkedMultiValueMap<>()); + testPathSegment("%20a%20", "", "%20a%20", " a ", new LinkedMultiValueMap<>()); + } + + @Test + public void pathSegmentWithParams() throws Exception { + // basic + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + params.add("colors", "red"); + params.add("colors", "blue"); + params.add("colors", "green"); + params.add("year", "2012"); + testPathSegment("cars", ";colors=red,blue,green;year=2012", "cars", "cars", params); + + // trailing semicolon + params = new LinkedMultiValueMap<>(); + params.add("p", "1"); + testPathSegment("path", ";p=1;", "path", "path", params); + + // params with spaces + params = new LinkedMultiValueMap<>(); + params.add("param name", "param value"); + testPathSegment("path", ";param%20name=param%20value;%20", "path", "path", params); + + // empty params + params = new LinkedMultiValueMap<>(); + params.add("p", "1"); + testPathSegment("path", ";;;%20;%20;p=1;%20", "path", "path", params); + } + + @Test + public void path() throws Exception { + // basic + testPath("/a/b/c", "/a/b/c", Arrays.asList("a", "b", "c")); + + // root path + testPath("/%20", "/%20", Collections.singletonList("%20")); + testPath("", "", Collections.emptyList()); + testPath("%20", "", Collections.emptyList()); + + // trailing slash + testPath("/a/b/", "/a/b/", Arrays.asList("a", "b", "")); + testPath("/a/b//", "/a/b//", Arrays.asList("a", "b", "", "")); + + // extra slashes ande spaces + testPath("//%20/%20", "//%20/%20", Arrays.asList("", "%20", "%20")); + } + + @Test + public void contextPath() throws Exception { + URI uri = URI.create("http://localhost:8080/app/a/b/c"); + RequestPath path = new DefaultRequestPath(uri, "/app", StandardCharsets.UTF_8); + + PathSegmentContainer contextPath = path.contextPath(); + assertEquals("/app", contextPath.value()); + assertEquals(Collections.singletonList("app"), pathSegmentValues(contextPath)); + + PathSegmentContainer pathWithinApplication = path.pathWithinApplication(); + assertEquals("/a/b/c", pathWithinApplication.value()); + assertEquals(Arrays.asList("a", "b", "c"), pathSegmentValues(pathWithinApplication)); + } + + + private void testPathSegment(String pathSegment, String semicolonContent, + String value, String valueDecoded, MultiValueMap parameters) { + + URI uri = URI.create("http://localhost:8080/" + pathSegment + semicolonContent); + PathSegment segment = new DefaultRequestPath(uri, "", StandardCharsets.UTF_8).pathSegments().get(0); + + assertEquals(value, segment.value()); + assertEquals(valueDecoded, segment.valueDecoded()); + assertEquals(semicolonContent, segment.semicolonContent()); + assertEquals(parameters, segment.parameters()); + } + + private void testPath(String input, String value, List segments) { + URI uri = URI.create("http://localhost:8080" + input); + RequestPath path = new DefaultRequestPath(uri, "", StandardCharsets.UTF_8); + + assertEquals(value, path.value()); + assertEquals(segments, pathSegmentValues(path)); + } + + private static List pathSegmentValues(PathSegmentContainer path) { + return path.pathSegments().stream().map(PathSegment::value).collect(Collectors.toList()); + } + +}