Use Problem in AcmeServerException

pull/55/head
Richard Körber 2017-05-01 15:24:44 +02:00
parent c623d72426
commit 02cedf9935
17 changed files with 162 additions and 77 deletions

View File

@ -172,7 +172,7 @@ public class Order extends AcmeResource {
URL certUrl = json.get("certificate").asURL();
certificate = certUrl != null ? Certificate.bind(getSession(), certUrl) : null;
this.error = json.get("error").asProblem();
this.error = json.get("error").asProblem(getLocation());
this.authorizations = json.get("authorizations").asArray().stream()
.map(JSON.Value::asURL)

View File

@ -14,6 +14,7 @@
package org.shredzone.acme4j;
import java.io.Serializable;
import java.net.URI;
import org.shredzone.acme4j.util.JSON;
@ -25,6 +26,7 @@ import org.shredzone.acme4j.util.JSON;
public class Problem implements Serializable {
private static final long serialVersionUID = -8418248862966754214L;
private final URI baseUri;
private final JSON problem;
/**
@ -32,16 +34,20 @@ public class Problem implements Serializable {
*
* @param problem
* Problem as JSON structure
* @param baseUri
* Document's base {@link URI} to resolve relative URIs against
*/
public Problem(JSON problem) {
public Problem(JSON problem, URI baseUri) {
this.problem = problem;
this.baseUri = baseUri;
}
/**
* Returns the problem type.
* Returns the problem type. It is always an absolute URI.
*/
public String getType() {
return problem.get("type").asString();
public URI getType() {
String type = problem.get("type").asString();
return type != null ? baseUri.resolve(type) : null;
}
/**
@ -51,6 +57,15 @@ public class Problem implements Serializable {
return problem.get("detail").asString();
}
/**
* Returns an URI that identifies the specific occurence of the problem. It is always
* an absolute URI.
*/
public URI getInstance() {
String instance = problem.get("instance").asString();
return instance != null ? baseUri.resolve(instance) : null;
}
/**
* Returns the problem as {@link JSON} object, to access other fields.
*/

View File

@ -43,6 +43,7 @@ import org.jose4j.base64url.Base64Url;
import org.jose4j.jwk.PublicJsonWebKey;
import org.jose4j.jws.JsonWebSignature;
import org.jose4j.lang.JoseException;
import org.shredzone.acme4j.Problem;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeNetworkException;
@ -237,7 +238,10 @@ public class DefaultConnection implements Connection {
throw new AcmeException("HTTP " + rc + ": " + conn.getResponseMessage());
}
throw createAcmeException(readJsonResponse());
Problem problem = new Problem(readJsonResponse(), conn.getURL().toURI());
throw createAcmeException(problem);
} catch (URISyntaxException ex) {
throw new AcmeProtocolException("Bad request URI: " + conn.getURL(), ex);
} catch (IOException ex) {
throw new AcmeNetworkException(ex);
}
@ -411,32 +415,29 @@ public class DefaultConnection implements Connection {
* {@link AcmeServerException} or subtype will be thrown. Otherwise a generic
* {@link AcmeException} is thrown.
*/
private AcmeException createAcmeException(JSON json) {
String type = json.get("type").asString();
String detail = json.get("detail").asString();
String error = AcmeUtils.stripErrorPrefix(type);
if (type == null) {
return new AcmeException(detail);
private AcmeException createAcmeException(Problem problem) {
if (problem.getType() == null) {
return new AcmeException(problem.getDetail());
}
String error = AcmeUtils.stripErrorPrefix(problem.getType().toString());
if ("unauthorized".equals(error)) {
return new AcmeUnauthorizedException(type, detail);
return new AcmeUnauthorizedException(problem);
}
if ("userActionRequired".equals(error)) {
URI instance = resolveRelative(json.get("instance").asString());
URI tos = getLinks("terms-of-service").stream().findFirst().orElse(null);
return new AcmeUserActionRequiredException(type, detail, tos, toURL(instance));
return new AcmeUserActionRequiredException(problem, tos);
}
if ("rateLimited".equals(error)) {
Optional<Instant> retryAfter = getRetryAfterHeader();
Collection<URI> rateLimits = getLinks("urn:ietf:params:acme:documentation");
return new AcmeRateLimitExceededException(type, detail, retryAfter.orElse(null), rateLimits);
return new AcmeRateLimitExceededException(problem, retryAfter.orElse(null), rateLimits);
}
return new AcmeServerException(type, detail);
return new AcmeServerException(problem);
}
/**

View File

@ -18,6 +18,8 @@ import java.time.Instant;
import java.util.Collection;
import java.util.Collections;
import org.shredzone.acme4j.Problem;
/**
* An exception that is thrown when a rate limit was exceeded.
*/
@ -30,19 +32,16 @@ public class AcmeRateLimitExceededException extends AcmeServerException {
/**
* Creates a new {@link AcmeRateLimitExceededException}.
*
* @param type
* System readable error type (here
* {@code "urn:ietf:params:acme:error:rateLimited"})
* @param detail
* Human readable error message
* @param problem
* {@link Problem} that caused the exception
* @param retryAfter
* The moment the request is expected to succeed again, may be {@code null}
* if not known
* @param documents
* URIs pointing to documents about the rate limit that was hit
*/
public AcmeRateLimitExceededException(String type, String detail, Instant retryAfter, Collection<URI> documents) {
super(type, detail);
public AcmeRateLimitExceededException(Problem problem, Instant retryAfter, Collection<URI> documents) {
super(problem);
this.retryAfter = retryAfter;
this.documents =
documents != null ? Collections.unmodifiableCollection(documents) : null;

View File

@ -13,8 +13,11 @@
*/
package org.shredzone.acme4j.exception;
import java.net.URI;
import java.util.Objects;
import org.shredzone.acme4j.Problem;
/**
* An exception that is thrown when the ACME server returned an error. It contains
* further details of the cause.
@ -22,27 +25,31 @@ import java.util.Objects;
public class AcmeServerException extends AcmeException {
private static final long serialVersionUID = 5971622508467042792L;
private final String type;
private final Problem problem;
/**
* Creates a new {@link AcmeServerException}.
*
* @param type
* System readable error type (e.g.
* {@code "urn:ietf:params:acme:error:malformed"})
* @param detail
* Human readable error message
* @param problem
* {@link Problem} that caused the exception
*/
public AcmeServerException(String type, String detail) {
super(detail);
this.type = Objects.requireNonNull(type, "type");
public AcmeServerException(Problem problem) {
super(Objects.requireNonNull(problem).getDetail());
this.problem = problem;
}
/**
* Returns the error type.
*/
public String getType() {
return type;
public URI getType() {
return problem.getType();
}
/**
* Returns the {@link Problem} that caused the exception
*/
public Problem getProblem() {
return problem;
}
}

View File

@ -13,6 +13,8 @@
*/
package org.shredzone.acme4j.exception;
import org.shredzone.acme4j.Problem;
/**
* An exception that is thrown when the client is not authorized. The details will give
* an explanation for the reasons (e.g. "client not on a whitelist").
@ -23,14 +25,11 @@ public class AcmeUnauthorizedException extends AcmeServerException {
/**
* Creates a new {@link AcmeUnauthorizedException}.
*
* @param type
* System readable error type (here
* {@code "urn:ietf:params:acme:error:unauthorized"})
* @param detail
* Human readable error message
* @param problem
* {@link Problem} that caused the exception
*/
public AcmeUnauthorizedException(String type, String detail) {
super(type, detail);
public AcmeUnauthorizedException(Problem problem) {
super(problem);
}
}

View File

@ -13,9 +13,12 @@
*/
package org.shredzone.acme4j.exception;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import org.shredzone.acme4j.Problem;
/**
* An exception that is thrown when the user is required to take action as indicated.
*/
@ -23,26 +26,18 @@ public class AcmeUserActionRequiredException extends AcmeServerException {
private static final long serialVersionUID = 7719055447283858352L;
private final URI tosUri;
private final URL instance;
/**
* Creates a new {@link AcmeUserActionRequiredException}.
*
* @param type
* System readable error type (here
* {@code "urn:ietf:params:acme:error:userActionRequired"})
* @param detail
* Human readable error message
* @param problem
* {@link Problem} that caused the exception
* @param tosUri
* {@link URI} of the terms-of-service document to accept
* @param instance
* {@link URL} to be visited by a human, showing instructions for how to
* agree to the terms and conditions.
*/
public AcmeUserActionRequiredException(String type, String detail, URI tosUri, URL instance) {
super(type, detail);
public AcmeUserActionRequiredException(Problem problem, URI tosUri) {
super(problem);
this.tosUri = tosUri;
this.instance = instance;
}
/**
@ -58,7 +53,13 @@ public class AcmeUserActionRequiredException extends AcmeServerException {
* or {@code null} if the server did not provide such a link.
*/
public URL getInstance() {
return instance;
try {
URI instance = getProblem().getInstance();
return instance != null ? instance.toURL() : null;
} catch (MalformedURLException ex) {
throw new AcmeProtocolException(
"Bad instance URL: " + getProblem().getInstance().toString(), ex);
}
}
}

View File

@ -306,14 +306,20 @@ public final class JSON implements Serializable {
/**
* Returns the value as {@link Problem}.
*
* @param baseUrl
* Base {@link URL} to resolve relative links against
* @return {@link Problem}, or {@code null} if the value was not set.
*/
public Problem asProblem() {
public Problem asProblem(URL baseUrl) {
if (val == null) {
return null;
}
return new Problem(asObject());
try {
return new Problem(asObject(), baseUrl.toURI());
} catch (URISyntaxException ex) {
throw new IllegalArgumentException("Bad base URL", ex);
}
}
/**

View File

@ -19,6 +19,7 @@ import static org.shredzone.acme4j.util.AcmeUtils.parseTimestamp;
import static org.shredzone.acme4j.util.TestUtils.*;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
@ -76,7 +77,7 @@ public class OrderTest {
assertThat(order.getCsr(), is(csr));
assertThat(order.getError(), is(notNullValue()));
assertThat(order.getError().getType(), is("urn:ietf:params:acme:error:connection"));
assertThat(order.getError().getType(), is(URI.create("urn:ietf:params:acme:error:connection")));
assertThat(order.getError().getDetail(), is("connection refused"));
List<Authorization> auths = order.getAuthorizations();

View File

@ -17,6 +17,8 @@ import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertThat;
import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs;
import java.net.URI;
import org.junit.Test;
import org.shredzone.acme4j.util.JSON;
import org.shredzone.acme4j.util.TestUtils;
@ -28,12 +30,14 @@ public class ProblemTest {
@Test
public void testProblem() {
URI baseUri = URI.create("https://example.com/acme/1");
JSON original = TestUtils.getJSON("problem");
Problem problem = new Problem(original);
Problem problem = new Problem(original, baseUri);
assertThat(problem.getType(), is("urn:ietf:params:acme:error:connection"));
assertThat(problem.getType(), is(URI.create("urn:ietf:params:acme:error:connection")));
assertThat(problem.getDetail(), is("connection refused"));
assertThat(problem.getInstance(), is(URI.create("https://example.com/documents/error.html")));
assertThat(problem.asJSON().toString(), is(sameJSONAs(original.toString())));
assertThat(problem.toString(), is(sameJSONAs(original.toString())));
}

View File

@ -16,6 +16,7 @@ package org.shredzone.acme4j.connector;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
import static org.shredzone.acme4j.util.TestUtils.url;
import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs;
import java.io.ByteArrayInputStream;
@ -425,13 +426,14 @@ public class DefaultConnectionTest {
when(mockUrlConnection.getHeaderField("Content-Type")).thenReturn("application/problem+json");
when(mockUrlConnection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_FORBIDDEN);
when(mockUrlConnection.getErrorStream()).thenReturn(new ByteArrayInputStream(jsonData.getBytes("utf-8")));
when(mockUrlConnection.getURL()).thenReturn(url("https://example.com/acme/1"));
try (DefaultConnection conn = new DefaultConnection(mockHttpConnection)) {
conn.conn = mockUrlConnection;
conn.accept(HttpURLConnection.HTTP_OK);
fail("Expected to fail");
} catch (AcmeServerException ex) {
assertThat(ex.getType(), is("urn:ietf:params:acme:error:unauthorized"));
assertThat(ex.getType(), is(URI.create("urn:ietf:params:acme:error:unauthorized")));
assertThat(ex.getMessage(), is("Invalid response: 404"));
} catch (AcmeException ex) {
fail("Expected an AcmeServerException");
@ -440,6 +442,7 @@ public class DefaultConnectionTest {
verify(mockUrlConnection, atLeastOnce()).getHeaderField("Content-Type");
verify(mockUrlConnection, atLeastOnce()).getResponseCode();
verify(mockUrlConnection).getErrorStream();
verify(mockUrlConnection).getURL();
verifyNoMoreInteractions(mockUrlConnection);
}
@ -452,6 +455,8 @@ public class DefaultConnectionTest {
.thenReturn("application/problem+json");
when(mockUrlConnection.getResponseCode())
.thenReturn(HttpURLConnection.HTTP_INTERNAL_ERROR);
when(mockUrlConnection.getURL())
.thenReturn(url("https://example.com/acme/1"));
try (DefaultConnection conn = new DefaultConnection(mockHttpConnection) {
@Override
@ -466,7 +471,7 @@ public class DefaultConnectionTest {
conn.accept(HttpURLConnection.HTTP_OK);
fail("Expected to fail");
} catch (AcmeServerException ex) {
assertThat(ex.getType(), is("urn:zombie:error:apocalypse"));
assertThat(ex.getType(), is(URI.create("urn:zombie:error:apocalypse")));
assertThat(ex.getMessage(), is("Zombie apocalypse in progress"));
} catch (AcmeException ex) {
fail("Expected an AcmeServerException");
@ -474,6 +479,7 @@ public class DefaultConnectionTest {
verify(mockUrlConnection).getHeaderField("Content-Type");
verify(mockUrlConnection, atLeastOnce()).getResponseCode();
verify(mockUrlConnection).getURL();
verifyNoMoreInteractions(mockUrlConnection);
}
@ -486,6 +492,8 @@ public class DefaultConnectionTest {
.thenReturn("application/problem+json");
when(mockUrlConnection.getResponseCode())
.thenReturn(HttpURLConnection.HTTP_INTERNAL_ERROR);
when(mockUrlConnection.getURL())
.thenReturn(url("https://example.com/acme/1"));
try (DefaultConnection conn = new DefaultConnection(mockHttpConnection) {
@Override
@ -504,6 +512,7 @@ public class DefaultConnectionTest {
verify(mockUrlConnection).getHeaderField("Content-Type");
verify(mockUrlConnection, atLeastOnce()).getResponseCode();
verify(mockUrlConnection).getURL();
verifyNoMoreInteractions(mockUrlConnection);
}

View File

@ -15,6 +15,7 @@ package org.shredzone.acme4j.exception;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.assertThat;
import static org.shredzone.acme4j.util.TestUtils.createProblem;
import java.net.URI;
import java.time.Duration;
@ -23,6 +24,7 @@ import java.util.Arrays;
import java.util.Collection;
import org.junit.Test;
import org.shredzone.acme4j.Problem;
/**
* Unit tests for {@link AcmeRateLimitExceededException}.
@ -34,15 +36,17 @@ public class AcmeRateLimitExceededExceptionTest {
*/
@Test
public void testAcmeRateLimitExceededException() {
String type = "urn:ietf:params:acme:error:rateLimited";
URI type = URI.create("urn:ietf:params:acme:error:rateLimited");
String detail = "Too many requests per minute";
Instant retryAfter = Instant.now().plus(Duration.ofMinutes(1));
Collection<URI> documents = Arrays.asList(
URI.create("http://example.com/doc1.html"),
URI.create("http://example.com/doc2.html"));
Problem problem = createProblem(type, detail, null);
AcmeRateLimitExceededException ex
= new AcmeRateLimitExceededException(type, detail, retryAfter, documents);
= new AcmeRateLimitExceededException(problem, retryAfter, documents);
assertThat(ex.getType(), is(type));
assertThat(ex.getMessage(), is(detail));
@ -55,11 +59,13 @@ public class AcmeRateLimitExceededExceptionTest {
*/
@Test
public void testNullAcmeRateLimitExceededException() {
String type = "urn:ietf:params:acme:error:rateLimited";
URI type = URI.create("urn:ietf:params:acme:error:rateLimited");
String detail = "Too many requests per minute";
Problem problem = createProblem(type, detail, null);
AcmeRateLimitExceededException ex
= new AcmeRateLimitExceededException(type, detail, null, null);
= new AcmeRateLimitExceededException(problem, null, null);
assertThat(ex.getType(), is(type));
assertThat(ex.getMessage(), is(detail));

View File

@ -15,12 +15,14 @@ package org.shredzone.acme4j.exception;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.assertThat;
import static org.shredzone.acme4j.util.TestUtils.createProblem;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import org.junit.Test;
import org.shredzone.acme4j.Problem;
/**
* Unit tests for {@link AcmeUserActionRequiredException}.
@ -32,13 +34,15 @@ public class AcmeUserActionRequiredExceptionTest {
*/
@Test
public void testAcmeUserActionRequiredException() throws MalformedURLException {
String type = "urn:ietf:params:acme:error:userActionRequired";
URI type = URI.create("urn:ietf:params:acme:error:userActionRequired");
String detail = "Accept new TOS";
URI tosUri = URI.create("http://example.com/agreement.pdf");
URL instanceUrl = new URL("http://example.com/howToAgree.html");
Problem problem = createProblem(type, detail, instanceUrl);
AcmeUserActionRequiredException ex
= new AcmeUserActionRequiredException(type, detail, tosUri, instanceUrl);
= new AcmeUserActionRequiredException(problem, tosUri);
assertThat(ex.getType(), is(type));
assertThat(ex.getMessage(), is(detail));
@ -51,11 +55,13 @@ public class AcmeUserActionRequiredExceptionTest {
*/
@Test
public void testNullAcmeUserActionRequiredException() {
String type = "urn:ietf:params:acme:error:userActionRequired";
URI type = URI.create("urn:ietf:params:acme:error:userActionRequired");
String detail = "Call our service";
Problem problem = createProblem(type, detail, null);
AcmeUserActionRequiredException ex
= new AcmeUserActionRequiredException(type, detail, null, null);
= new AcmeUserActionRequiredException(problem, null);
assertThat(ex.getType(), is(type));
assertThat(ex.getMessage(), is(detail));

View File

@ -26,6 +26,7 @@ import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
@ -45,6 +46,8 @@ import org.shredzone.acme4j.exception.AcmeProtocolException;
*/
public class JSONTest {
private static final URL BASE_URL = url("https://example.com/acme/1");
/**
* Test that an empty {@link JSON} is empty.
*/
@ -210,10 +213,11 @@ public class JSONTest {
JSON sub = array.get(3).asObject();
assertThat(sub.get("test").asString(), is("ok"));
Problem problem = json.get("problem").asProblem();
Problem problem = json.get("problem").asProblem(BASE_URL);
assertThat(problem, is(notNullValue()));
assertThat(problem.getType(), is("urn:ietf:params:acme:error:rateLimited"));
assertThat(problem.getType(), is(URI.create("urn:ietf:params:acme:error:rateLimited")));
assertThat(problem.getDetail(), is("too many requests"));
assertThat(problem.getInstance(), is(URI.create("https://example.com/documents/errors.html")));
}
/**
@ -231,7 +235,7 @@ public class JSONTest {
assertThat(json.get("none").asObject(), is(nullValue()));
assertThat(json.get("none").asStatusOrElse(Status.INVALID), is(Status.INVALID));
assertThat(json.get("none").asBinary(), is(nullValue()));
assertThat(json.get("none").asProblem(), is(nullValue()));
assertThat(json.get("none").asProblem(BASE_URL), is(nullValue()));
try {
json.get("none").asInt();
@ -308,7 +312,7 @@ public class JSONTest {
}
try {
json.get("text").asProblem();
json.get("text").asProblem(BASE_URL);
fail("no exception was thrown");
} catch (AcmeProtocolException ex) {
// expected

View File

@ -52,6 +52,7 @@ import org.jose4j.base64url.Base64Url;
import org.jose4j.json.JsonUtil;
import org.jose4j.jwk.JsonWebKey;
import org.jose4j.jwk.JsonWebKey.OutputControlLevel;
import org.shredzone.acme4j.Problem;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.provider.AcmeProvider;
@ -268,6 +269,30 @@ public final class TestUtils {
};
}
/**
* Creates a {@link Problem} with the given type and details.
*
* @param type
* Problem type
* @param detail
* Problem details
* @param instance
* Instance, or {@code null}
* @return Created {@link Problem} object
*/
public static Problem createProblem(URI type, String detail, URL instance) {
URI baseUri = URI.create("https://example.com/acme/1");
JSONBuilder jb = new JSONBuilder();
jb.put("type", type);
jb.put("detail", detail);
if (instance != null) {
jb.put("instance", instance);
}
return new Problem(jb.toJSON(), baseUri);
}
/**
* Generates a new keypair for unit tests, and return its N, E, KTY and THUMBPRINT
* parameters to be set in the {@link TestUtils} class.

View File

@ -11,6 +11,7 @@
"binary": "Q2hhaW5zYXc",
"problem": {
"type": "urn:ietf:params:acme:error:rateLimited",
"detail": "too many requests"
"detail": "too many requests",
"instance": "/documents/errors.html"
}
}

View File

@ -1,4 +1,5 @@
{
"type": "urn:ietf:params:acme:error:connection",
"detail": "connection refused"
"detail": "connection refused",
"instance": "/documents/error.html"
}