Enhance Accept-Language header

Before this patch, it was only the language tag of the selected Locale.
Now it also offers the language itself (without the country) and any
other available language as fallback. It is also possible to set the
locale to null, which will accept any language.
pull/140/head
Richard Körber 2023-04-24 21:27:06 +02:00
parent b0287d4d94
commit 41bc574f75
No known key found for this signature in database
GPG Key ID: AAB9FD19C78AA3E0
6 changed files with 111 additions and 22 deletions

View File

@ -32,6 +32,7 @@ import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.provider.AcmeProvider; import org.shredzone.acme4j.provider.AcmeProvider;
import org.shredzone.acme4j.provider.GenericAcmeProvider; import org.shredzone.acme4j.provider.GenericAcmeProvider;
import org.shredzone.acme4j.toolbox.AcmeUtils;
import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSON.Value; import org.shredzone.acme4j.toolbox.JSON.Value;
@ -49,7 +50,8 @@ public class Session {
private final AcmeProvider provider; private final AcmeProvider provider;
private @Nullable String nonce; private @Nullable String nonce;
private Locale locale = Locale.getDefault(); private @Nullable Locale locale = Locale.getDefault();
private String languageHeader = AcmeUtils.localeToLanguageHeader(Locale.getDefault());
protected @Nullable ZonedDateTime directoryLastModified; protected @Nullable ZonedDateTime directoryLastModified;
protected @Nullable ZonedDateTime directoryExpires; protected @Nullable ZonedDateTime directoryExpires;
@ -147,8 +149,10 @@ public class Session {
} }
/** /**
* Gets the current locale of this session. * Gets the current locale of this session, or {@code null} if no special language is
* selected.
*/ */
@Nullable
public Locale getLocale() { public Locale getLocale() {
return locale; return locale;
} }
@ -156,9 +160,21 @@ public class Session {
/** /**
* Sets the locale used in this session. The locale is passed to the server as * Sets the locale used in this session. The locale is passed to the server as
* Accept-Language header. The server <em>may</em> respond with localized messages. * Accept-Language header. The server <em>may</em> respond with localized messages.
* The default is the system's language. If set to {@code null}, no special language
* is selected.
*/ */
public void setLocale(@Nullable Locale locale) { public void setLocale(@Nullable Locale locale) {
this.locale = locale != null ? locale : Locale.getDefault(); this.locale = locale;
this.languageHeader = AcmeUtils.localeToLanguageHeader(locale);
}
/**
* Gets an Accept-Language header value that matches the current locale.
*
* @since 3.0.0
*/
public String getLanguageHeader() {
return languageHeader;
} }
/** /**

View File

@ -347,7 +347,7 @@ public class DefaultConnection implements Connection {
try { try {
var builder = httpConnector.createRequestBuilder(url) var builder = httpConnector.createRequestBuilder(url)
.header(ACCEPT_CHARSET_HEADER, DEFAULT_CHARSET) .header(ACCEPT_CHARSET_HEADER, DEFAULT_CHARSET)
.header(ACCEPT_LANGUAGE_HEADER, session.getLocale().toLanguageTag()); .header(ACCEPT_LANGUAGE_HEADER, session.getLanguageHeader());
if (session.networkSettings().isCompressionEnabled()) { if (session.networkSettings().isCompressionEnabled()) {
builder.header(ACCEPT_ENCODING_HEADER, "gzip"); builder.header(ACCEPT_ENCODING_HEADER, "gzip");

View File

@ -26,6 +26,7 @@ import java.time.Instant;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.Base64; import java.util.Base64;
import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -221,6 +222,29 @@ public final class AcmeUtils {
ZoneId.of(tz)).toInstant(); ZoneId.of(tz)).toInstant();
} }
/**
* Converts the given locale to an Accept-Language header value.
*
* @param locale
* {@link Locale} to be used in the header
* @return Value that can be used in an Accept-Language header
*/
public static String localeToLanguageHeader(@Nullable Locale locale) {
if (locale == null || "und".equals(locale.toLanguageTag())) {
return "*";
}
var langTag = locale.toLanguageTag();
var header = new StringBuilder(langTag);
if (langTag.indexOf('-') >= 0) {
header.append(',').append(locale.getLanguage()).append(";q=0.8");
}
header.append(",*;q=0.1");
return header.toString();
}
/** /**
* Strips the acme error prefix from the error string. * Strips the acme error prefix from the error string.
* <p> * <p>

View File

@ -23,6 +23,7 @@ import java.net.URI;
import java.net.URL; import java.net.URL;
import java.time.Duration; import java.time.Duration;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.Locale;
import org.assertj.core.api.AutoCloseableSoftAssertions; import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -31,6 +32,7 @@ import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.provider.AcmeProvider; import org.shredzone.acme4j.provider.AcmeProvider;
import org.shredzone.acme4j.provider.GenericAcmeProvider; import org.shredzone.acme4j.provider.GenericAcmeProvider;
import org.shredzone.acme4j.toolbox.AcmeUtils;
import org.shredzone.acme4j.toolbox.TestUtils; import org.shredzone.acme4j.toolbox.TestUtils;
/** /**
@ -212,4 +214,28 @@ public class SessionTest {
} }
} }
/**
* Test that the locale is properly set.
*/
@Test
public void testLocale() {
var session = new Session(URI.create(TestUtils.ACME_SERVER_URI));
// default configuration
assertThat(session.getLocale())
.isEqualTo(Locale.getDefault());
assertThat(session.getLanguageHeader())
.isEqualTo(AcmeUtils.localeToLanguageHeader(Locale.getDefault()));
// null
session.setLocale(null);
assertThat(session.getLocale()).isNull();
assertThat(session.getLanguageHeader()).isEqualTo("*");
// a locale
session.setLocale(Locale.CANADA_FRENCH);
assertThat(session.getLocale()).isEqualTo(Locale.CANADA_FRENCH);
assertThat(session.getLanguageHeader()).isEqualTo("fr-CA,fr;q=0.8,*;q=0.1");
}
} }

View File

@ -72,6 +72,9 @@ public class DefaultConnectionTest {
private static final String DIRECTORY_PATH = "/dir"; private static final String DIRECTORY_PATH = "/dir";
private static final String NEW_NONCE_PATH = "/newNonce"; private static final String NEW_NONCE_PATH = "/newNonce";
private static final String REQUEST_PATH = "/test/test"; private static final String REQUEST_PATH = "/test/test";
private static final String TEST_ACCEPT_LANGUAGE = "ja-JP,ja;q=0.8,*;q=0.1";
private static final String TEST_ACCEPT_CHARSET = "utf-8";
private static final String TEST_USER_AGENT_PATTERN = "^acme4j/.*$";
private final URL accountUrl = TestUtils.url(TestUtils.ACCOUNT_URL); private final URL accountUrl = TestUtils.url(TestUtils.ACCOUNT_URL);
private Session session; private Session session;
@ -564,9 +567,9 @@ public class DefaultConnectionTest {
verify(getRequestedFor(urlEqualTo(REQUEST_PATH)) verify(getRequestedFor(urlEqualTo(REQUEST_PATH))
.withHeader("Accept", equalTo("application/json")) .withHeader("Accept", equalTo("application/json"))
.withHeader("Accept-Charset", equalTo("utf-8")) .withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET))
.withHeader("Accept-Language", equalTo("ja-JP")) .withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE))
.withHeader("User-Agent", matching("^acme4j/.*$")) .withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN))
); );
} }
@ -589,9 +592,9 @@ public class DefaultConnectionTest {
verify(getRequestedFor(urlEqualTo(REQUEST_PATH)) verify(getRequestedFor(urlEqualTo(REQUEST_PATH))
.withHeader("If-Modified-Since", equalToDateTime(ifModifiedSince)) .withHeader("If-Modified-Since", equalToDateTime(ifModifiedSince))
.withHeader("Accept", equalTo("application/json")) .withHeader("Accept", equalTo("application/json"))
.withHeader("Accept-Charset", equalTo("utf-8")) .withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET))
.withHeader("Accept-Language", equalTo("ja-JP")) .withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE))
.withHeader("User-Agent", matching("^acme4j/.*$")) .withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN))
); );
} }
@ -620,9 +623,9 @@ public class DefaultConnectionTest {
verify(postRequestedFor(urlEqualTo(REQUEST_PATH)) verify(postRequestedFor(urlEqualTo(REQUEST_PATH))
.withHeader("Accept", equalTo("application/json")) .withHeader("Accept", equalTo("application/json"))
.withHeader("Accept-Charset", equalTo("utf-8")) .withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET))
.withHeader("Accept-Language", equalTo("ja-JP")) .withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE))
.withHeader("User-Agent", matching("^acme4j/.*$")) .withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN))
); );
var requests = findAll(postRequestedFor(urlEqualTo(REQUEST_PATH))); var requests = findAll(postRequestedFor(urlEqualTo(REQUEST_PATH)));
@ -673,10 +676,10 @@ public class DefaultConnectionTest {
verify(postRequestedFor(urlEqualTo(REQUEST_PATH)) verify(postRequestedFor(urlEqualTo(REQUEST_PATH))
.withHeader("Accept", equalTo("application/json")) .withHeader("Accept", equalTo("application/json"))
.withHeader("Accept-Charset", equalTo("utf-8")) .withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET))
.withHeader("Accept-Language", equalTo("ja-JP")) .withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE))
.withHeader("Content-Type", equalTo("application/jose+json")) .withHeader("Content-Type", equalTo("application/jose+json"))
.withHeader("User-Agent", matching("^acme4j/.*$")) .withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN))
); );
var requests = findAll(postRequestedFor(urlEqualTo(REQUEST_PATH))); var requests = findAll(postRequestedFor(urlEqualTo(REQUEST_PATH)));
@ -727,10 +730,10 @@ public class DefaultConnectionTest {
verify(postRequestedFor(urlEqualTo(REQUEST_PATH)) verify(postRequestedFor(urlEqualTo(REQUEST_PATH))
.withHeader("Accept", equalTo("application/pem-certificate-chain")) .withHeader("Accept", equalTo("application/pem-certificate-chain"))
.withHeader("Accept-Charset", equalTo("utf-8")) .withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET))
.withHeader("Accept-Language", equalTo("ja-JP")) .withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE))
.withHeader("Content-Type", equalTo("application/jose+json")) .withHeader("Content-Type", equalTo("application/jose+json"))
.withHeader("User-Agent", matching("^acme4j/.*$")) .withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN))
); );
} }
@ -758,10 +761,10 @@ public class DefaultConnectionTest {
verify(postRequestedFor(urlEqualTo(REQUEST_PATH)) verify(postRequestedFor(urlEqualTo(REQUEST_PATH))
.withHeader("Accept", equalTo("application/json")) .withHeader("Accept", equalTo("application/json"))
.withHeader("Accept-Charset", equalTo("utf-8")) .withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET))
.withHeader("Accept-Language", equalTo("ja-JP")) .withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE))
.withHeader("Content-Type", equalTo("application/jose+json")) .withHeader("Content-Type", equalTo("application/jose+json"))
.withHeader("User-Agent", matching("^acme4j/.*$")) .withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN))
); );
var requests = findAll(postRequestedFor(urlEqualTo(REQUEST_PATH))); var requests = findAll(postRequestedFor(urlEqualTo(REQUEST_PATH)));

View File

@ -27,6 +27,7 @@ import java.net.URI;
import java.security.Security; import java.security.Security;
import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateEncodingException;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.Locale;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jce.provider.BouncyCastleProvider;
@ -201,6 +202,25 @@ public class AcmeUtilsTest {
"accepted string without time"); "accepted string without time");
} }
/**
* Test that locales are correctly converted to language headers.
*/
@Test
public void testLocaleToLanguageHeader() {
assertThat(localeToLanguageHeader(Locale.ENGLISH))
.isEqualTo("en,*;q=0.1");
assertThat(localeToLanguageHeader(new Locale("en", "US")))
.isEqualTo("en-US,en;q=0.8,*;q=0.1");
assertThat(localeToLanguageHeader(Locale.GERMAN))
.isEqualTo("de,*;q=0.1");
assertThat(localeToLanguageHeader(Locale.GERMANY))
.isEqualTo("de-DE,de;q=0.8,*;q=0.1");
assertThat(localeToLanguageHeader(new Locale("")))
.isEqualTo("*");
assertThat(localeToLanguageHeader(null))
.isEqualTo("*");
}
/** /**
* Test that error prefix is correctly removed. * Test that error prefix is correctly removed.
*/ */