clean up
This commit is contained in:
parent
b23fe28c42
commit
a63557cdd9
8 changed files with 102 additions and 67 deletions
|
@ -2,7 +2,6 @@ package org.xbib.net;
|
|||
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.BitSet;
|
||||
|
||||
import static java.nio.charset.CodingErrorAction.REPORT;
|
||||
|
||||
/**
|
||||
|
|
|
@ -5,5 +5,5 @@ package org.xbib.net;
|
|||
*/
|
||||
public enum ProtocolVersion {
|
||||
|
||||
IPV4, IPV6, IPV46, NONE
|
||||
IPV4, IPV6, NONE
|
||||
}
|
||||
|
|
|
@ -63,6 +63,10 @@ public class URL implements Comparable<URL> {
|
|||
|
||||
private static final char AT_CHAR = '@';
|
||||
|
||||
private static final char LEFT_BRACKET_CHAR = '[';
|
||||
|
||||
private static final char RIGHT_BRACKET_CHAR = ']';
|
||||
|
||||
private static final String DOUBLE_SLASH = "//";
|
||||
|
||||
private static final String EMPTY = "";
|
||||
|
@ -71,10 +75,20 @@ public class URL implements Comparable<URL> {
|
|||
|
||||
private final transient Builder builder;
|
||||
|
||||
private final transient Charset charset;
|
||||
|
||||
private final transient Scheme scheme;
|
||||
|
||||
private final transient PercentEncoder queryParamEncoder;
|
||||
|
||||
private final transient PercentEncoder regNameEncoder;
|
||||
|
||||
private final transient PercentEncoder pathEncoder;
|
||||
|
||||
private final transient PercentEncoder matrixEncoder;
|
||||
|
||||
private final transient PercentEncoder queryEncoder;
|
||||
|
||||
private final transient PercentEncoder fragmentEncoder;
|
||||
|
||||
private final String hostinfo;
|
||||
|
||||
private final String path;
|
||||
|
@ -89,19 +103,19 @@ public class URL implements Comparable<URL> {
|
|||
|
||||
private URL(Builder builder) {
|
||||
this.builder = builder;
|
||||
this.charset = builder.charset;
|
||||
this.scheme = SchemeRegistry.getInstance().getScheme(builder.scheme);
|
||||
this.queryParamEncoder = PercentEncoders.getQueryParamEncoder(builder.charset);
|
||||
this.regNameEncoder = PercentEncoders.getRegNameEncoder(builder.charset);
|
||||
this.pathEncoder = PercentEncoders.getPathEncoder(builder.charset);
|
||||
this.matrixEncoder = PercentEncoders.getMatrixEncoder(builder.charset);
|
||||
this.queryEncoder = PercentEncoders.getQueryEncoder(builder.charset);
|
||||
this.fragmentEncoder = PercentEncoders.getFragmentEncoder(builder.charset);
|
||||
this.hostinfo = encodeHostInfo();
|
||||
this.path = encodePath();
|
||||
this.query = encodeQuery();
|
||||
this.fragment = encodeFragment();
|
||||
}
|
||||
|
||||
/**
|
||||
* A special, scheme-less URL denoting the fact that this URL should be considered as invalid.
|
||||
*/
|
||||
private static final URL INVALID = URL.builder().build();
|
||||
|
||||
public static Builder file() {
|
||||
return new Builder().scheme(Scheme.FILE);
|
||||
}
|
||||
|
@ -234,8 +248,14 @@ public class URL implements Comparable<URL> {
|
|||
return new Resolver(URL.create(base));
|
||||
}
|
||||
|
||||
public static URL getInvalid() {
|
||||
return INVALID;
|
||||
private static final URL NULL_URL = URL.builder().build();
|
||||
|
||||
/**
|
||||
* Return a special URL denoting the fact that this URL should be considered as invalid.
|
||||
* The URL has a null scheme.
|
||||
*/
|
||||
public static URL nullUrl() {
|
||||
return NULL_URL;
|
||||
}
|
||||
|
||||
public static URL from(String input) {
|
||||
|
@ -331,6 +351,10 @@ public class URL implements Comparable<URL> {
|
|||
return builder.userInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user of the user info.
|
||||
* @return the user
|
||||
*/
|
||||
public String getUser() {
|
||||
if (builder.userInfo == null) {
|
||||
return null;
|
||||
|
@ -355,6 +379,10 @@ public class URL implements Comparable<URL> {
|
|||
return builder.host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the decoded host name.
|
||||
* @return the decoded host name
|
||||
*/
|
||||
public String getDecodedHost() {
|
||||
return decode(builder.host);
|
||||
}
|
||||
|
@ -372,13 +400,16 @@ public class URL implements Comparable<URL> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get the encoded path ('/path/to/my/file.html') of the {@code URL} if it exists.
|
||||
* @return the encoded path
|
||||
* Get the path ('/path/to/my/file.html') of the {@code URL} if it exists.
|
||||
* @return the path
|
||||
*/
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the percent-decoded path of the {@code URL} if it exists.
|
||||
*/
|
||||
public String getDecodedPath() {
|
||||
return decode(path);
|
||||
}
|
||||
|
@ -458,18 +489,16 @@ public class URL implements Comparable<URL> {
|
|||
private String toInternalForm(boolean withFragment) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (!isNullOrEmpty(builder.scheme)) {
|
||||
sb.append(builder.scheme).append(':');
|
||||
sb.append(builder.scheme).append(COLON_CHAR);
|
||||
}
|
||||
if (isOpaque()) {
|
||||
sb.append(builder.schemeSpecificPart);
|
||||
} else {
|
||||
appendHostInfo(sb, false, true);
|
||||
appendPath(sb, false);
|
||||
if (!isNullOrEmpty(query)) {
|
||||
sb.append(QUESTION_CHAR).append(query);
|
||||
}
|
||||
if (!isNullOrEmpty(fragment) && withFragment) {
|
||||
sb.append(NUMBER_SIGN_CHAR).append(fragment);
|
||||
appendQuery(sb, false, true);
|
||||
if (withFragment) {
|
||||
appendFragment(sb, false, true);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
|
@ -478,7 +507,7 @@ public class URL implements Comparable<URL> {
|
|||
private String writeExternalForm() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (!isNullOrEmpty(builder.scheme)) {
|
||||
sb.append(builder.scheme).append(':');
|
||||
sb.append(builder.scheme).append(COLON_CHAR);
|
||||
}
|
||||
if (isOpaque()) {
|
||||
sb.append(builder.schemeSpecificPart);
|
||||
|
@ -521,17 +550,16 @@ public class URL implements Comparable<URL> {
|
|||
if (s != null && !s.equals(builder.hostAddress)) {
|
||||
sb.append(s);
|
||||
} else if (builder.hostAddress != null) {
|
||||
sb.append("[").append(builder.hostAddress).append("]");
|
||||
sb.append(LEFT_BRACKET_CHAR).append(builder.hostAddress).append(RIGHT_BRACKET_CHAR);
|
||||
}
|
||||
break;
|
||||
case IPV4:
|
||||
case IPV46:
|
||||
sb.append(builder.host);
|
||||
break;
|
||||
default:
|
||||
if (encoded) {
|
||||
try {
|
||||
String encodedHostName = PercentEncoders.getRegNameEncoder(charset).encode(builder.host);
|
||||
String encodedHostName = regNameEncoder.encode(builder.host);
|
||||
validateHostnameCharacters(encodedHostName);
|
||||
sb.append(encodedHostName);
|
||||
} catch (CharacterCodingException e) {
|
||||
|
@ -545,7 +573,7 @@ public class URL implements Comparable<URL> {
|
|||
} else {
|
||||
if (encoded) {
|
||||
try {
|
||||
String encodedHostName = PercentEncoders.getRegNameEncoder(charset).encode(builder.host);
|
||||
String encodedHostName = regNameEncoder.encode(builder.host);
|
||||
validateHostnameCharacters(encodedHostName);
|
||||
sb.append(encodedHostName);
|
||||
} catch (CharacterCodingException e) {
|
||||
|
@ -556,7 +584,7 @@ public class URL implements Comparable<URL> {
|
|||
}
|
||||
}
|
||||
if (scheme != null && builder.port != null && builder.port != scheme.getDefaultPort()) {
|
||||
sb.append(':');
|
||||
sb.append(COLON_CHAR);
|
||||
if (builder.port != -1) {
|
||||
sb.append(builder.port);
|
||||
}
|
||||
|
@ -587,8 +615,6 @@ public class URL implements Comparable<URL> {
|
|||
}
|
||||
|
||||
private void appendPath(StringBuilder sb, boolean encoded) {
|
||||
PercentEncoder pathEncoder = PercentEncoders.getPathEncoder(charset);
|
||||
PercentEncoder matrixEncoder = PercentEncoders.getMatrixEncoder(charset);
|
||||
Iterator<PathSegment> it = builder.pathSegments.iterator();
|
||||
while (it.hasNext()) {
|
||||
PathSegment pathSegment = it.next();
|
||||
|
@ -623,7 +649,6 @@ public class URL implements Comparable<URL> {
|
|||
sb.append(QUESTION_CHAR);
|
||||
}
|
||||
Iterator<QueryParameters.Pair<String, String>> it = builder.queryParams.iterator();
|
||||
PercentEncoder queryParamEncoder = PercentEncoders.getQueryParamEncoder(charset);
|
||||
while (it.hasNext()) {
|
||||
QueryParameters.Pair<String, String> queryParam = it.next();
|
||||
try {
|
||||
|
@ -645,7 +670,7 @@ public class URL implements Comparable<URL> {
|
|||
}
|
||||
if (encoded) {
|
||||
try {
|
||||
sb.append(PercentEncoders.getQueryEncoder(charset).encode(builder.query));
|
||||
sb.append(queryEncoder.encode(builder.query));
|
||||
} catch (CharacterCodingException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
|
@ -668,7 +693,7 @@ public class URL implements Comparable<URL> {
|
|||
}
|
||||
if (encoded) {
|
||||
try {
|
||||
sb.append(PercentEncoders.getFragmentEncoder(charset).encode(builder.fragment));
|
||||
sb.append(fragmentEncoder.encode(builder.fragment));
|
||||
} catch (CharacterCodingException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
|
@ -713,10 +738,13 @@ public class URL implements Comparable<URL> {
|
|||
}
|
||||
|
||||
/**
|
||||
* The URL Builder embedded class is for building an URL by fluent API methods.
|
||||
* The URL builder class is required for building an URL. It uses fluent API methods
|
||||
* and pre-processes paralameter accordingly.
|
||||
*/
|
||||
public static class Builder {
|
||||
|
||||
private PercentEncoder regNameEncoder;
|
||||
|
||||
private final PercentDecoder percentDecoder;
|
||||
|
||||
private final QueryParameters queryParams;
|
||||
|
@ -746,20 +774,26 @@ public class URL implements Comparable<URL> {
|
|||
private boolean fatalResolveErrorsEnabled;
|
||||
|
||||
private Builder() {
|
||||
charset(StandardCharsets.UTF_8);
|
||||
this.percentDecoder = new PercentDecoder();
|
||||
this.queryParams = new QueryParameters();
|
||||
this.pathSegments = new ArrayList<>();
|
||||
this.charset = StandardCharsets.UTF_8;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the character set of the URL. Default is UTF-8.
|
||||
* @param charset the chaarcter set
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder charset(Charset charset) {
|
||||
this.charset = charset;
|
||||
this.regNameEncoder = PercentEncoders.getRegNameEncoder(charset);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder scheme(String scheme) {
|
||||
if (!isNullOrEmpty(scheme)) {
|
||||
validateSchemeCharacters(scheme.toLowerCase());
|
||||
validateSchemeCharacters(scheme.toLowerCase(Locale.ROOT));
|
||||
this.scheme = scheme;
|
||||
}
|
||||
return this;
|
||||
|
@ -777,8 +811,8 @@ public class URL implements Comparable<URL> {
|
|||
|
||||
public Builder userInfo(String user, String pass) {
|
||||
try {
|
||||
this.userInfo = PercentEncoders.getRegNameEncoder(charset).encode(user) + ':' +
|
||||
PercentEncoders.getRegNameEncoder(charset).encode(pass);
|
||||
// allow colons in usernames and passwords by percent-encoding them here
|
||||
this.userInfo = regNameEncoder.encode(user) + COLON_CHAR + regNameEncoder.encode(pass);
|
||||
} catch (MalformedInputException | UnmappableCharacterException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
|
@ -827,8 +861,8 @@ public class URL implements Comparable<URL> {
|
|||
}
|
||||
logger.log(Level.WARNING, e.getMessage(), e);
|
||||
if (e.getMessage() != null && !e.getMessage().endsWith("invalid IPv6 address") &&
|
||||
hostname.charAt(0) != '[' &&
|
||||
hostname.charAt(hostname.length() - 1) != ']') {
|
||||
hostname.charAt(0) != LEFT_BRACKET_CHAR &&
|
||||
hostname.charAt(hostname.length() - 1) != RIGHT_BRACKET_CHAR) {
|
||||
try {
|
||||
String idna = IDN.toASCII(percentDecoder.decode(hostname));
|
||||
host(idna, ProtocolVersion.NONE);
|
||||
|
@ -969,7 +1003,7 @@ public class URL implements Comparable<URL> {
|
|||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* A URL parser class.
|
||||
*/
|
||||
public static class Parser {
|
||||
|
||||
|
@ -987,13 +1021,13 @@ public class URL implements Comparable<URL> {
|
|||
public URL parse(String input, boolean resolve)
|
||||
throws URLSyntaxException, MalformedInputException, UnmappableCharacterException {
|
||||
if (isNullOrEmpty(input)) {
|
||||
return INVALID;
|
||||
return NULL_URL;
|
||||
}
|
||||
if (input.indexOf('\n') >= 0) {
|
||||
return INVALID;
|
||||
return NULL_URL;
|
||||
}
|
||||
if (input.indexOf('\t') >= 0) {
|
||||
return INVALID;
|
||||
return NULL_URL;
|
||||
}
|
||||
String remaining = parseScheme(builder, input);
|
||||
if (remaining != null) {
|
||||
|
@ -1011,7 +1045,7 @@ public class URL implements Comparable<URL> {
|
|||
String host = (pos >= 0 ? remaining.substring(0, pos) : remaining);
|
||||
parseHostAndPort(builder, parseUserInfo(builder, host), resolve);
|
||||
if (builder.host == null) {
|
||||
return INVALID;
|
||||
return NULL_URL;
|
||||
}
|
||||
remaining = pos >= 0 ? remaining.substring(pos) : EMPTY;
|
||||
}
|
||||
|
@ -1053,14 +1087,14 @@ public class URL implements Comparable<URL> {
|
|||
private void parseHostAndPort(Builder builder, String rawHost, boolean resolve)
|
||||
throws URLSyntaxException {
|
||||
String host = rawHost;
|
||||
if (host.indexOf('[') == 0) {
|
||||
int i = host.lastIndexOf(']');
|
||||
if (host.indexOf(LEFT_BRACKET_CHAR) == 0) {
|
||||
int i = host.lastIndexOf(RIGHT_BRACKET_CHAR);
|
||||
if (i >= 0) {
|
||||
builder.port(parsePort(host.substring(i + 1)));
|
||||
host = host.substring(1, i);
|
||||
}
|
||||
} else {
|
||||
int i = host.indexOf(':');
|
||||
int i = host.indexOf(COLON_CHAR);
|
||||
if (i >= 0) {
|
||||
builder.port(parsePort(host.substring(i)));
|
||||
host = host.substring(0, i);
|
||||
|
@ -1077,7 +1111,7 @@ public class URL implements Comparable<URL> {
|
|||
if (portStr == null || portStr.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
int i = portStr.indexOf(":");
|
||||
int i = portStr.indexOf(COLON_CHAR);
|
||||
if (i >= 0) {
|
||||
portStr = portStr.substring(i + 1);
|
||||
if (portStr.isEmpty()) {
|
||||
|
@ -1179,8 +1213,7 @@ public class URL implements Comparable<URL> {
|
|||
}
|
||||
|
||||
/**
|
||||
* The URL resolver class is an embedded class for resolving a relative URL specification to
|
||||
* a base URL.
|
||||
* The URL resolver class is a class for resolving a relative URL specification to a base URL.
|
||||
*/
|
||||
public static class Resolver {
|
||||
|
||||
|
@ -1204,7 +1237,7 @@ public class URL implements Comparable<URL> {
|
|||
|
||||
public URL resolve(URL relative)
|
||||
throws URLSyntaxException {
|
||||
if (relative == null || relative == INVALID) {
|
||||
if (relative == null || relative.equals(NULL_URL)) {
|
||||
throw new URLSyntaxException("relative URL is invalid");
|
||||
}
|
||||
if (!base.isAbsolute()) {
|
||||
|
|
|
@ -4,7 +4,8 @@ import java.util.Arrays;
|
|||
import java.util.BitSet;
|
||||
|
||||
/**
|
||||
*
|
||||
* The character matcher class is a fast table-based matcher class, able to match whitespace, control,
|
||||
* literals, persent or hexdigit character groups.
|
||||
*/
|
||||
public abstract class CharMatcher {
|
||||
|
||||
|
|
|
@ -3,6 +3,8 @@ package org.xbib.net.scheme;
|
|||
import org.xbib.net.URL;
|
||||
import org.xbib.net.path.PathNormalizer;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* HTTP scheme.
|
||||
*/
|
||||
|
@ -20,7 +22,7 @@ class HttpScheme extends AbstractScheme {
|
|||
public URL normalize(URL url) {
|
||||
String host = url.getHost();
|
||||
if (host != null) {
|
||||
host = host.toLowerCase();
|
||||
host = host.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
return URL.builder()
|
||||
.scheme(url.getScheme())
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package org.xbib.net.scheme;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.ServiceLoader;
|
||||
|
||||
|
@ -62,7 +63,7 @@ public final class SchemeRegistry {
|
|||
return false;
|
||||
}
|
||||
if (!schemes.containsKey(name)) {
|
||||
schemes.put(name.toLowerCase(), scheme);
|
||||
schemes.put(name.toLowerCase(Locale.ROOT), scheme);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
|
@ -73,7 +74,7 @@ public final class SchemeRegistry {
|
|||
if (scheme == null) {
|
||||
return null;
|
||||
}
|
||||
Scheme s = schemes.get(scheme.toLowerCase());
|
||||
Scheme s = schemes.get(scheme.toLowerCase(Locale.ROOT));
|
||||
return s != null ? s : new DefaultScheme(scheme);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,14 +22,14 @@ public class IRITest {
|
|||
@Test
|
||||
public void testIpv6() {
|
||||
URL iri = URL.from("http://[2001:0db8:85a3:08d3:1319:8a2e:0370:7344]");
|
||||
assertTrue(iri.getProtocolVersion().equals(ProtocolVersion.IPV6));
|
||||
assertEquals(iri.getProtocolVersion(), ProtocolVersion.IPV6);
|
||||
assertEquals("http://[2001:db8:85a3:8d3:1319:8a2e:370:7344]", iri.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testIpv6Invalid() {
|
||||
URL iri = URL.from("http://[2001:0db8:85a3:08d3:1319:8a2e:0370:734o]");
|
||||
assertEquals(URL.getInvalid(), iri);
|
||||
assertEquals(URL.nullUrl(), iri);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -14,22 +14,22 @@ public class URLParserTest {
|
|||
|
||||
@Test
|
||||
public void testNull() {
|
||||
assertEquals(URL.getInvalid(), URL.from(null));
|
||||
assertEquals(URL.nullUrl(), URL.from(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEmpty() {
|
||||
assertEquals(URL.getInvalid(), URL.from(""));
|
||||
assertEquals(URL.nullUrl(), URL.from(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNewline() {
|
||||
assertEquals(URL.getInvalid(), URL.from("\n"));
|
||||
assertEquals(URL.nullUrl(), URL.from("\n"));
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException.class)
|
||||
public void testInvalidScheme() throws Exception {
|
||||
URL url = URL.from("/:23");
|
||||
public void testInvalidScheme() {
|
||||
URL.from("/:23");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -62,7 +62,7 @@ public class URLParserTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void testGopher() throws Exception {
|
||||
public void testGopher() {
|
||||
URL url = URL.from("gopher:/example.com/");
|
||||
assertEquals("gopher:/example.com/", url.toExternalForm());
|
||||
}
|
||||
|
@ -76,7 +76,7 @@ public class URLParserTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void testSlashAfterScheme() throws Exception {
|
||||
public void testSlashAfterScheme() {
|
||||
URL url = URL.from("http:/example.com/");
|
||||
assertEquals("http:/example.com/", url.toExternalForm());
|
||||
}
|
||||
|
@ -96,7 +96,7 @@ public class URLParserTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void testNetworkLocation() throws Exception {
|
||||
public void testNetworkLocation() {
|
||||
URL url = URL.from("//foo.bar");
|
||||
assertEquals("//foo.bar", url.toExternalForm());
|
||||
assertEquals("//foo.bar", url.toString());
|
||||
|
@ -131,7 +131,7 @@ public class URLParserTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void testBackslash() throws Exception {
|
||||
public void testBackslash() {
|
||||
URL url = URL.from("http://foo.com/\\@");
|
||||
assertEquals("http://foo.com/@", url.toExternalForm());
|
||||
}
|
||||
|
@ -153,7 +153,6 @@ public class URLParserTest {
|
|||
@Test
|
||||
public void testReservedChar() throws Exception {
|
||||
URL url = URL.from("http://www.google.com/ig/calculator?q=1USD=?EUR");
|
||||
//assertEquals("http://www.google.com/ig/calculator?q=1USD=?EUR", url.toString());
|
||||
if ("false".equals(System.getProperty("java.net.preferIPv6Addresses"))) {
|
||||
assertEquals("http://www.google.com/ig/calculator?q=1USD%3D?EUR", url.toString());
|
||||
}
|
||||
|
@ -163,7 +162,7 @@ public class URLParserTest {
|
|||
@Test
|
||||
public void testPlus() throws Exception {
|
||||
URL url = URL.from("http://foobar:8080/test/print?value=%EA%B0%80+%EB%82%98");
|
||||
assertEquals("http://foobar:8080/test/print?value=%EA%B0%80%2B%EB%82%98", url.toString());
|
||||
assertEquals("http://foobar:8080/test/print?value=%EA%B0%80%2B%EB%82%98", url.toExternalForm());
|
||||
assertRoundTrip(url.toExternalForm());
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue