Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add HttpUrl class #49

Merged
merged 4 commits into from
Apr 17, 2019
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/*
* (c) Copyright 2019 Palantir Technologies Inc. All rights reserved.
*
* 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 com.palantir.dialogue;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.CharMatcher;
import com.google.common.base.Joiner;
import com.google.common.collect.Iterables;
import com.google.common.net.InetAddresses;
import com.palantir.logsafe.Preconditions;
import com.palantir.logsafe.UnsafeArg;
import java.io.ByteArrayOutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

/** A simplistic URL builder, not tuned for performance. */
public final class UrlBuilder {

private static final Joiner SLASH_JOINER = Joiner.on('/');

private final String protocol;
private String host;
private int port = -1;
private List<String> pathSegments = new ArrayList<>();
private List<String> queryNamesAndValues = new ArrayList<>(); // alternating (name, value) pairs
markelliot marked this conversation as resolved.
Show resolved Hide resolved

public static UrlBuilder http() {
return new UrlBuilder("http");
}

public static UrlBuilder https() {
return new UrlBuilder("https");
}

private UrlBuilder(String protocol) {
this.protocol = protocol;
}

/**
* Accepts regular names (e.g., {@code google.com}), IPv4 addresses in dot notation (e.g.,
* {@code 192.168.0.1}), and IPv6 addresses of the form
* {@code [2010:836B:4179::836B:4179]} (note the enclosing square brackets).
*/
public UrlBuilder host(String theHost) {
this.host = theHost;
return this;
}

public UrlBuilder port(int thePort) {
this.port = thePort;
markelliot marked this conversation as resolved.
Show resolved Hide resolved
return this;
}

/** URL-encodes the given path segment and adds it to the list of segments. */
public UrlBuilder pathSegment(String thePath) {
this.pathSegments.add(thePath);
return this;
}

/** URL-encodes the given query parameter name and value and adds them to the list of query parameters. */
public UrlBuilder queryParam(String name, String value) {
this.queryNamesAndValues.add(name);
this.queryNamesAndValues.add(value);
return this;
}

public URL build() {
try {
Preconditions.checkNotNull(protocol, "protocol must be set");
Preconditions.checkNotNull(host, "host must be set");
Preconditions.checkArgument(port != -1, "port must be set");

Preconditions.checkArgument(UrlEncoder.isHost(host),
"invalid host format", UnsafeArg.of("host", host));

StringBuilder file = new StringBuilder();
encodePath(pathSegments, file);
encodeQuery(queryNamesAndValues, file);

return new URL(protocol, host, port, file.toString());
} catch (MalformedURLException e) {
throw new IllegalArgumentException("Malformed URL", e);
}
}

private static void encodePath(List<String> segments, StringBuilder result) {
if (!segments.isEmpty()) {
result.append('/');
}
SLASH_JOINER.appendTo(result, Iterables.transform(segments, UrlEncoder::encodePathSegment));
}

private static void encodeQuery(List<String> pairs, StringBuilder result) {
if (!pairs.isEmpty()) {
result.append('?');
}
for (int i = 0; i < pairs.size(); i += 2) {
result.append(UrlEncoder.encodeQueryNameOrValue(pairs.get(i)));
result.append('=');
result.append(UrlEncoder.encodeQueryNameOrValue(pairs.get(i + 1)));
if (i < pairs.size() - 2) {
result.append('&');
}
}
}

/** Encodes URL components per https://tools.ietf.org/html/rfc3986 . */
@VisibleForTesting
static class UrlEncoder {
private static final CharMatcher DIGIT = CharMatcher.inRange('0', '9');
private static final CharMatcher ALPHA = CharMatcher.inRange('a', 'z').or(CharMatcher.inRange('A', 'Z'));
private static final CharMatcher UNRESERVED = DIGIT.or(ALPHA).or(CharMatcher.anyOf("-._~"));
private static final CharMatcher SUB_DELIMS = CharMatcher.anyOf("!$&'()*+,;=");
private static final CharMatcher IS_HOST = UNRESERVED.or(SUB_DELIMS);
private static final CharMatcher IS_P_CHAR = UNRESERVED.or(SUB_DELIMS);
private static final CharMatcher IS_QUERY_CHAR =
CharMatcher.anyOf("=&").negate().and(IS_P_CHAR.or(CharMatcher.anyOf("?/")));

static boolean isHost(String maybeHost) {
return IS_HOST.matchesAllOf(maybeHost) || isIpv6Host(maybeHost);
}

static boolean isIpv6Host(String maybeHost) {
int length = maybeHost.length();
return length > 2
&& maybeHost.codePointAt(0) == '['
&& maybeHost.codePointAt(length - 1) == ']'
&& InetAddresses.isInetAddress(maybeHost.substring(1, length - 1));
}

static String encodePathSegment(String pathComponent) {
return encode(pathComponent, IS_P_CHAR);
}

static String encodeQueryNameOrValue(String nameOrValue) {
return encode(nameOrValue, IS_QUERY_CHAR);
}

// percent-encodes every byte in the source string with it's percent-encoded representation, except for bytes
// that (in their unsigned char sense) are matched by charactersToKeep
@VisibleForTesting
static String encode(String source, CharMatcher charactersToKeep) {
byte[] bytes = source.getBytes(StandardCharsets.UTF_8);
ByteArrayOutputStream bos = new ByteArrayOutputStream(source.length()); // approx sizing
boolean wasChanged = false;
for (byte b : bytes) {
if (charactersToKeep.matches(toChar(b))) {
bos.write(b);
} else {
bos.write('%');
char hex1 = Character.toUpperCase(Character.forDigit((b >> 4) & 0xF, 16));
char hex2 = Character.toUpperCase(Character.forDigit(b & 0xF, 16));
bos.write(hex1);
bos.write(hex2);
wasChanged = true;
}
}
return wasChanged
? new String(bos.toByteArray(), StandardCharsets.UTF_8)
: source;
}

// converts the given (signed) byte into an (unsigned) char
private static char toChar(byte theByte) {
if (theByte < 0) {
return (char) (256 + theByte);
} else {
return (char) theByte;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* (c) Copyright 2019 Palantir Technologies Inc. All rights reserved.
*
* 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 com.palantir.dialogue;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.junit.MockitoJUnitRunner;

@RunWith(MockitoJUnitRunner.class)
public final class UrlBuilderTest {

@Test
public void differentProtocols() {
assertThat(UrlBuilder.http().host("host").port(80).build().toString()).isEqualTo("http://host:80");
assertThat(UrlBuilder.https().host("host").port(80).build().toString()).isEqualTo("https://host:80");
}

@Test
public void differentHosts() {
assertThat(UrlBuilder.http().host("host").port(80).build().toString()).isEqualTo("http://host:80");
assertThat(UrlBuilder.http().host("another-host").port(80).build().toString())
.isEqualTo("http://another-host:80");
assertThatThrownBy(() -> UrlBuilder.http().build()).hasMessage("host must be set");
}

@Test
public void differentPorts() {
assertThat(UrlBuilder.http().host("host").port(80).build().toString()).isEqualTo("http://host:80");
assertThat(UrlBuilder.http().host("host").port(8080).build().toString()).isEqualTo("http://host:8080");
assertThatThrownBy(() -> UrlBuilder.http().host("host").build())
.hasMessage("port must be set");
}

@Test
public void encodesPaths() {
assertThat(minimalUrl().pathSegment("foo").build().toString())
.isEqualTo("http://host:80/foo");
assertThat(minimalUrl().pathSegment("foo").pathSegment("bar").build().toString())
.isEqualTo("http://host:80/foo/bar");
assertThat(minimalUrl().pathSegment("foo/bar").build().toString())
.isEqualTo("http://host:80/foo%2Fbar");
assertThat(minimalUrl().pathSegment("!@#$%^&*()_+{}[]|\\|\"':;/?.>,<~`").build().toString())
.isEqualTo("http://host:80/!%40%23$%25%5E&*()_+%7B%7D%5B%5D%7C%5C%7C%22'%3A;%2F%3F.%3E,%3C~%60");
}

@Test
public void encodesQueryParams() {
assertThat(minimalUrl().queryParam("foo", "bar").build().toString())
.isEqualTo("http://host:80?foo=bar");
assertThat(minimalUrl().queryParam("question?&", "answer!&").build().toString())
.isEqualTo("http://host:80?question?%26=answer!%26");
}

@Test
public void encodesMultipleQueryParamsWithSameName() {
assertThat(minimalUrl().queryParam("foo", "bar").queryParam("foo", "baz").build().toString())
.isEqualTo("http://host:80?foo=bar&foo=baz");
}

@Test
public void fullExample() {
assertThat(UrlBuilder.https()
.host("host")
.port(80)
.pathSegment("foo")
.pathSegment("bar")
.queryParam("boom", "baz")
.queryParam("question", "answer")
.build().toString())
.isEqualTo("https://host:80/foo/bar?boom=baz&question=answer");
}

@Test
public void urlEncoder_isHost_acceptsHostsPerRfc() {
assertThat(UrlBuilder.UrlEncoder.isHost("aAzZ09!$&'()*+,;=")).isTrue();
assertThat(UrlBuilder.UrlEncoder.isHost("192.168.0.1")).isTrue();
assertThat(UrlBuilder.UrlEncoder.isHost("[2010:836B:4179::836B:4179]")).isTrue();

assertThat(UrlBuilder.UrlEncoder.isHost("ö")).isFalse();
assertThat(UrlBuilder.UrlEncoder.isHost("#")).isFalse();
assertThat(UrlBuilder.UrlEncoder.isHost("@")).isFalse();
assertThat(UrlBuilder.UrlEncoder.isHost("2010:836B:4179::836B:4179")).isFalse();
}

@Test
public void urlEncoder_encodePathSegment_onlyEncodesNonReservedChars() {
String nonReserved = "aAzZ09!$&'()*+,;=";
assertThat(UrlBuilder.UrlEncoder.encodePathSegment(nonReserved)).isEqualTo(nonReserved);
assertThat(UrlBuilder.UrlEncoder.encodePathSegment("/")).isEqualTo("%2F");
}

@Test
public void urlEncoder_encodeQuery_onlyEncodesNonReservedChars() {
String nonReserved = "aAzZ09!$'()*+,;?/";
assertThat(UrlBuilder.UrlEncoder.encodeQueryNameOrValue(nonReserved)).isEqualTo(nonReserved);
assertThat(UrlBuilder.UrlEncoder.encodeQueryNameOrValue("@[]{}ßçö"))
.isEqualTo("%40%5B%5D%7B%7D%C3%9F%C3%A7%C3%B6");
assertThat(UrlBuilder.UrlEncoder.encodeQueryNameOrValue("&=")).isEqualTo("%26%3D");
}

private static UrlBuilder minimalUrl() {
return UrlBuilder.http().host("host").port(80);
}
}