From 252d886b3f929a8c271f8e452f3c67e5e630f954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Sun, 14 Jan 2018 14:07:25 +0100 Subject: [PATCH] Refactor ACME resource classes A new AcmeJsonResource takes care of fetching and keeping the resource state. A lot of boilerplate code could be removed that way. --- .../java/org/shredzone/acme4j/Account.java | 99 +++-------- .../org/shredzone/acme4j/AccountBuilder.java | 2 +- .../shredzone/acme4j/AcmeJsonResource.java | 132 +++++++++++++++ .../org/shredzone/acme4j/Authorization.java | 124 +++----------- .../main/java/org/shredzone/acme4j/Order.java | 128 ++++----------- .../org/shredzone/acme4j/OrderBuilder.java | 5 +- .../java/org/shredzone/acme4j/Session.java | 2 +- .../shredzone/acme4j/challenge/Challenge.java | 74 ++------- .../org/shredzone/acme4j/AccountTest.java | 8 +- .../acme4j/AcmeJsonResourceTest.java | 154 ++++++++++++++++++ .../shredzone/acme4j/AuthorizationTest.java | 2 +- .../java/org/shredzone/acme4j/OrderTest.java | 16 ++ .../acme4j/challenge/ChallengeTest.java | 26 +-- .../acme4j/challenge/DnsChallengeTest.java | 2 +- .../acme4j/challenge/HttpChallengeTest.java | 4 +- .../challenge/TlsSni02ChallengeTest.java | 2 +- .../src/test/resources/json/dnsChallenge.json | 1 + .../test/resources/json/httpChallenge.json | 1 + .../resources/json/httpNoTokenChallenge.json | 1 + .../test/resources/json/modifyAccount.json | 1 + .../resources/json/modifyAccountResponse.json | 1 + .../resources/json/tlsSni02Challenge.json | 1 + 22 files changed, 423 insertions(+), 363 deletions(-) create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/AcmeJsonResource.java create mode 100644 acme4j-client/src/test/java/org/shredzone/acme4j/AcmeJsonResourceTest.java diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java index 9de03471..1bb45622 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java @@ -13,6 +13,7 @@ */ package org.shredzone.acme4j; +import static java.util.stream.Collectors.toList; import static org.shredzone.acme4j.toolbox.AcmeUtils.*; import java.net.HttpURLConnection; @@ -33,11 +34,10 @@ import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.connector.Resource; import org.shredzone.acme4j.connector.ResourceIterator; import org.shredzone.acme4j.exception.AcmeException; -import org.shredzone.acme4j.exception.AcmeLazyLoadingException; import org.shredzone.acme4j.exception.AcmeProtocolException; -import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.exception.AcmeServerException; import org.shredzone.acme4j.toolbox.JSON; +import org.shredzone.acme4j.toolbox.JSON.Value; import org.shredzone.acme4j.toolbox.JSONBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,7 +45,7 @@ import org.slf4j.LoggerFactory; /** * Represents an account at the ACME server. */ -public class Account extends AcmeResource { +public class Account extends AcmeJsonResource { private static final long serialVersionUID = 7042863483428051319L; private static final Logger LOG = LoggerFactory.getLogger(Account.class); @@ -54,12 +54,6 @@ public class Account extends AcmeResource { private static final String KEY_CONTACT = "contact"; private static final String KEY_STATUS = "status"; - private final List contacts = new ArrayList<>(); - private Status status; - private Boolean termsOfServiceAgreed; - private URL orders; - private boolean loaded = false; - protected Account(Session session, URL location) { super(session); setLocation(location); @@ -86,24 +80,25 @@ public class Account extends AcmeResource { * {@code null} if the server did not provide such an information. */ public Boolean getTermsOfServiceAgreed() { - load(); - return termsOfServiceAgreed; + return getJSON().get(KEY_TOS_AGREED).optional().map(Value::asBoolean).orElse(null); } /** * List of contact addresses (emails, phone numbers etc). */ public List getContacts() { - load(); - return Collections.unmodifiableList(contacts); + return Collections.unmodifiableList(getJSON().get(KEY_CONTACT) + .asArray() + .stream() + .map(JSON.Value::asURI) + .collect(toList())); } /** * Returns the current status of the account. */ public Status getStatus() { - load(); - return status; + return getJSON().get(KEY_STATUS).asStatusOrElse(Status.UNKNOWN); } /** @@ -117,23 +112,17 @@ public class Account extends AcmeResource { * fetched from the server. */ public Iterator getOrders() throws AcmeException { - LOG.debug("getOrders"); - load(); - return new ResourceIterator<>(getSession(), KEY_ORDERS, orders, Order::bind); + URL ordersUrl = getJSON().get(KEY_ORDERS).asURL(); + return new ResourceIterator<>(getSession(), KEY_ORDERS, ordersUrl, Order::bind); } - /** - * Updates the account to the current account status. - */ + @Override public void update() throws AcmeException { - LOG.debug("update"); + LOG.debug("update Account"); try (Connection conn = getSession().provider().connect()) { - JSONBuilder claims = new JSONBuilder(); - - conn.sendSignedRequest(getLocation(), claims, getSession()); - - unmarshal(conn.readJsonResponse()); - } + conn.sendSignedRequest(getLocation(), new JSONBuilder(), getSession()); + setJSON(conn.readJsonResponse()); + } } /** @@ -181,10 +170,8 @@ public class Account extends AcmeResource { conn.sendSignedRequest(newAuthzUrl, claims, getSession(), HttpURLConnection.HTTP_CREATED); - JSON json = conn.readJsonResponse(); - Authorization auth = new Authorization(getSession(), conn.getLocation()); - auth.unmarshalAuthorization(json); + auth.setJSON(conn.readJsonResponse()); return auth; } } @@ -250,53 +237,10 @@ public class Account extends AcmeResource { conn.sendSignedRequest(getLocation(), claims, getSession()); - unmarshal(conn.readJsonResponse()); + setJSON(conn.readJsonResponse()); } } - /** - * Lazily updates the object's state when one of the getters is invoked. - */ - protected void load() { - if (!loaded) { - try { - update(); - } catch (AcmeRetryAfterException ex) { - // ignore... The object was still updated. - LOG.debug("Retry-After", ex); - } catch (AcmeException ex) { - throw new AcmeLazyLoadingException(this, ex); - } - } - } - - /** - * Sets account properties according to the given JSON data. - * - * @param json - * JSON data - */ - protected void unmarshal(JSON json) { - if (json.contains(KEY_TOS_AGREED)) { - this.termsOfServiceAgreed = json.get(KEY_TOS_AGREED).asBoolean(); - } - - if (json.contains(KEY_CONTACT)) { - contacts.clear(); - json.get(KEY_CONTACT).asArray().stream() - .map(JSON.Value::asURI) - .forEach(contacts::add); - } - - this.orders = json.get(KEY_ORDERS).asURL(); - - if (json.contains(KEY_STATUS)) { - this.status = Status.parse(json.get(KEY_STATUS).asString()); - } - - loaded = true; - } - /** * Modifies the account data of the account. * @@ -313,7 +257,7 @@ public class Account extends AcmeResource { private final List editContacts = new ArrayList<>(); private EditableAccount() { - editContacts.addAll(Account.this.contacts); + editContacts.addAll(Account.this.getContacts()); } /** @@ -363,8 +307,7 @@ public class Account extends AcmeResource { conn.sendSignedRequest(getLocation(), claims, getSession()); - JSON json = conn.readJsonResponse(); - unmarshal(json); + setJSON(conn.readJsonResponse()); } } } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/AccountBuilder.java b/acme4j-client/src/main/java/org/shredzone/acme4j/AccountBuilder.java index 0dc181e8..2db922bc 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/AccountBuilder.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/AccountBuilder.java @@ -187,7 +187,7 @@ public class AccountBuilder { } if (resp == HttpURLConnection.HTTP_CREATED) { - account.unmarshal(conn.readJsonResponse()); + account.setJSON(conn.readJsonResponse()); } return account; } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/AcmeJsonResource.java b/acme4j-client/src/main/java/org/shredzone/acme4j/AcmeJsonResource.java new file mode 100644 index 00000000..bd4054fb --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/AcmeJsonResource.java @@ -0,0 +1,132 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2018 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j; + +import java.util.Objects; + +import org.shredzone.acme4j.connector.Connection; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.exception.AcmeLazyLoadingException; +import org.shredzone.acme4j.exception.AcmeRetryAfterException; +import org.shredzone.acme4j.toolbox.JSON; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An ACME resource that stores its state in a JSON structure. + */ +public abstract class AcmeJsonResource extends AcmeResource { + private static final long serialVersionUID = -5060364275766082345L; + private static final Logger LOG = LoggerFactory.getLogger(AcmeJsonResource.class); + + private JSON data = null; + + /** + * Create a new {@link AcmeJsonResource}. + * + * @param session + * {@link Session} the resource is bound with + */ + protected AcmeJsonResource(Session session) { + super(session); + } + + /** + * Create a new {@link AcmeJsonResource} and use the given data. + * + * @param session + * {@link Session} the resource is bound with + * @param data + * Initial {@link JSON} data + */ + protected AcmeJsonResource(Session session, JSON data) { + super(session); + setJSON(data); + } + + /** + * Returns the JSON representation of the resource data. + *

+ * If there is no data, {@link #update()} is invoked to fetch it from the server. + *

+ * This method can be used to read proprietary data from the resources. + * + * @return Resource data, as {@link JSON}. + */ + public JSON getJSON() { + if (data == null) { + try { + update(); + } catch (AcmeRetryAfterException ex) { + // ignore... The object was still updated. + LOG.debug("Retry-After", ex); + } catch (AcmeException ex) { + throw new AcmeLazyLoadingException(this, ex); + } + } + return data; + } + + /** + * Sets the JSON representation of the resource data. + * + * @param data + * New {@link JSON} data, must not be {@code null}. + */ + protected void setJSON(JSON data) { + this.data = Objects.requireNonNull(data, "data"); + } + + /** + * Checks if this resource is valid. + * + * @return {@code true} if the resource state has been loaded from the server. If + * {@code false}, {@link #getJSON()} would implicitly call {@link #update()} + * to fetch the current state from the server. + */ + protected boolean isValid() { + return data != null; + } + + /** + * Invalidates the state of this resource. Enforces an {@link #update()} when + * {@link #getJSON()} is invoked. + */ + protected void invalidate() { + data = null; + } + + /** + * Updates this resource, by fetching the current resource data from the server. + * + * @throws AcmeException + * if the resource could not be fetched. + * @throws AcmeRetryAfterException + * the resource is still being processed, and the server returned an + * estimated date when the process will be completed. If you are polling + * for the resource to complete, you should wait for the date given in + * {@link AcmeRetryAfterException#getRetryAfter()}. Note that the status + * of the resource is updated even if this exception was thrown. + */ + public void update() throws AcmeException { + String resourceType = getClass().getSimpleName(); + LOG.debug("update {}", resourceType); + try (Connection conn = getSession().provider().connect()) { + conn.sendRequest(getLocation(), getSession()); + setJSON(conn.readJsonResponse()); + conn.handleRetryAfter(resourceType + " is not completed yet"); + } + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java index baefec3d..25edcd58 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java @@ -14,7 +14,6 @@ package org.shredzone.acme4j; import static java.util.stream.Collectors.toList; -import static org.shredzone.acme4j.toolbox.AcmeUtils.parseTimestamp; import java.net.URL; import java.time.Instant; @@ -24,10 +23,10 @@ import java.util.List; import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.exception.AcmeException; -import org.shredzone.acme4j.exception.AcmeLazyLoadingException; import org.shredzone.acme4j.exception.AcmeProtocolException; -import org.shredzone.acme4j.exception.AcmeRetryAfterException; +import org.shredzone.acme4j.toolbox.AcmeUtils; import org.shredzone.acme4j.toolbox.JSON; +import org.shredzone.acme4j.toolbox.JSON.Value; import org.shredzone.acme4j.toolbox.JSONBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,16 +34,10 @@ import org.slf4j.LoggerFactory; /** * Represents an authorization request at the ACME server. */ -public class Authorization extends AcmeResource { +public class Authorization extends AcmeJsonResource { private static final long serialVersionUID = -3116928998379417741L; private static final Logger LOG = LoggerFactory.getLogger(Authorization.class); - private String domain; - private Status status; - private Instant expires; - private List challenges; - private boolean loaded = false; - protected Authorization(Session session, URL location) { super(session); setLocation(location); @@ -68,32 +61,43 @@ public class Authorization extends AcmeResource { * Gets the domain name to be authorized. */ public String getDomain() { - load(); - return domain; + JSON jsonIdentifier = getJSON().get("identifier").required().asObject(); + String type = jsonIdentifier.get("type").required().asString(); + if (!"dns".equals(type)) { + throw new AcmeProtocolException("Unknown authorization type: " + type); + } + return jsonIdentifier.get("value").required().asString(); } /** * Gets the authorization status. */ public Status getStatus() { - load(); - return status; + return getJSON().get("status").asStatusOrElse(Status.PENDING); } /** * Gets the expiry date of the authorization, if set by the server. */ public Instant getExpires() { - load(); - return expires; + return getJSON().get("expires").optional() + .map(Value::asString) + .map(AcmeUtils::parseTimestamp) + .orElse(null); } /** * Gets a list of all challenges offered by the server. */ public List getChallenges() { - load(); - return challenges; + Session session = getSession(); + + return Collections.unmodifiableList(getJSON().get("challenges") + .asArray() + .stream() + .map(Value::asObject) + .map(session::createChallenge) + .collect(toList())); } /** @@ -115,28 +119,6 @@ public class Authorization extends AcmeResource { .orElse(null); } - /** - * Updates the {@link Authorization}. After invocation, the {@link Authorization} - * reflects the current state at the ACME server. - * - * @throws AcmeRetryAfterException - * the auhtorization is still being validated, and the server returned an - * estimated date when the validation will be completed. If you are - * polling for the authorization to complete, you should wait for the date - * given in {@link AcmeRetryAfterException#getRetryAfter()}. Note that the - * authorization status is updated even if this exception was thrown. - */ - public void update() throws AcmeException { - LOG.debug("update"); - try (Connection conn = getSession().provider().connect()) { - conn.sendRequest(getLocation(), getSession()); - - unmarshalAuthorization(conn.readJsonResponse()); - - conn.handleRetryAfter("authorization is not completed yet"); - } - } - /** * Permanently deactivates the {@link Authorization}. */ @@ -148,68 +130,8 @@ public class Authorization extends AcmeResource { conn.sendSignedRequest(getLocation(), claims, getSession()); - unmarshalAuthorization(conn.readJsonResponse()); + setJSON(conn.readJsonResponse()); } } - /** - * Lazily updates the object's state when one of the getters is invoked. - */ - protected void load() { - if (!loaded) { - try { - update(); - } catch (AcmeRetryAfterException ex) { - // ignore... The object was still updated. - LOG.debug("Retry-After", ex); - } catch (AcmeException ex) { - throw new AcmeLazyLoadingException(this, ex); - } - } - } - - /** - * Sets the properties according to the given JSON data. - * - * @param json - * JSON data - */ - protected void unmarshalAuthorization(JSON json) { - this.status = json.get("status").asStatusOrElse(Status.PENDING); - - String jsonExpires = json.get("expires").asString(); - if (jsonExpires != null) { - expires = parseTimestamp(jsonExpires); - } - - JSON jsonIdentifier = json.get("identifier").asObject(); - if (jsonIdentifier != null) { - String type = jsonIdentifier.get("type").asString(); - if (type != null && !"dns".equals(type)) { - throw new AcmeProtocolException("Unknown authorization type: " + type); - } - domain = jsonIdentifier.get("value").asString(); - } - - challenges = fetchChallenges(json); - - loaded = true; - } - - /** - * Fetches all {@link Challenge} that are defined in the JSON. - * - * @param json - * {@link JSON} to read - * @return List of {@link Challenge} - */ - private List fetchChallenges(JSON json) { - Session session = getSession(); - - return Collections.unmodifiableList(json.get("challenges").asArray().stream() - .map(JSON.Value::asObject) - .map(session::createChallenge) - .collect(toList())); - } - } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java index e622325f..06991ee9 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java @@ -17,12 +17,12 @@ import static java.util.stream.Collectors.toList; import java.net.URL; import java.time.Instant; +import java.util.Collections; import java.util.List; import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.exception.AcmeException; -import org.shredzone.acme4j.exception.AcmeLazyLoadingException; -import org.shredzone.acme4j.toolbox.JSON; +import org.shredzone.acme4j.toolbox.JSON.Value; import org.shredzone.acme4j.toolbox.JSONBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,21 +30,10 @@ import org.slf4j.LoggerFactory; /** * Represents a certificate order. */ -public class Order extends AcmeResource { +public class Order extends AcmeJsonResource { private static final long serialVersionUID = 5435808648658292177L; private static final Logger LOG = LoggerFactory.getLogger(Order.class); - private Status status; - private Instant expires; - private List identifiers; - private Instant notBefore; - private Instant notAfter; - private Problem error; - private List authorizations; - private URL finalizeUrl; - private Certificate certificate; - private boolean loaded = false; - protected Order(Session session, URL location) { super(session); setLocation(location); @@ -67,58 +56,60 @@ public class Order extends AcmeResource { * Returns the current status of the order. */ public Status getStatus() { - load(); - return status; + return getJSON().get("status").asStatusOrElse(Status.UNKNOWN); } /** * Returns a {@link Problem} document if the order failed. */ public Problem getError() { - load(); - return error; + return getJSON().get("error").asProblem(getLocation()); } /** * Gets the expiry date of the authorization, if set by the server. */ public Instant getExpires() { - load(); - return expires; + return getJSON().get("expires").asInstant(); } /** * Gets the list of domain names to be ordered. */ public List getDomains() { - return identifiers; + return Collections.unmodifiableList(getJSON().get("identifiers") + .asArray() + .stream() + .map(Value::asObject) + .map(it -> it.get("value").asString()) + .collect(toList())); } /** * Gets the "not before" date that was used for the order, or {@code null}. */ public Instant getNotBefore() { - load(); - return notBefore; + return getJSON().get("notBefore").asInstant(); } /** * Gets the "not after" date that was used for the order, or {@code null}. */ public Instant getNotAfter() { - load(); - return notAfter; + return getJSON().get("notAfter").asInstant(); } /** * Gets the {@link Authorization} required for this order. */ public List getAuthorizations() { - load(); Session session = getSession(); - return authorizations.stream() - .map(loc -> Authorization.bind(session, loc)) - .collect(toList()); + return Collections.unmodifiableList(getJSON().get("authorizations") + .asArray() + .stream() + .map(Value::asURL) + .map(loc -> Authorization.bind(session, loc)) + .collect(toList())); } /** @@ -127,8 +118,17 @@ public class Order extends AcmeResource { * For internal purposes. Use {@link #execute(byte[])} to finalize an order. */ public URL getFinalizeLocation() { - load(); - return finalizeUrl; + return getJSON().get("finalize").asURL(); + } + + /** + * Gets the {@link Certificate} if it is available. {@code null} otherwise. + */ + public Certificate getCertificate() { + return getJSON().get("certificate").optional() + .map(Value::asURL) + .map(certUrl -> Certificate.bind(getSession(), certUrl)) + .orElse(null); } /** @@ -153,71 +153,7 @@ public class Order extends AcmeResource { conn.sendSignedRequest(getFinalizeLocation(), claims, getSession()); } - loaded = false; // invalidate this object - } - - /** - * Gets the {@link Certificate} if it is available. {@code null} otherwise. - */ - public Certificate getCertificate() { - load(); - return certificate; - } - - /** - * Updates the order to the current account status. - */ - public void update() throws AcmeException { - LOG.debug("update"); - try (Connection conn = getSession().provider().connect()) { - conn.sendRequest(getLocation(), getSession()); - - JSON json = conn.readJsonResponse(); - unmarshal(json); - } - } - - /** - * Lazily updates the object's state when one of the getters is invoked. - */ - protected void load() { - if (!loaded) { - try { - update(); - } catch (AcmeException ex) { - throw new AcmeLazyLoadingException(this, ex); - } - } - } - - /** - * Sets order properties according to the given JSON data. - * - * @param json - * JSON data - */ - public void unmarshal(JSON json) { - this.status = json.get("status").asStatusOrElse(Status.UNKNOWN); - this.expires = json.get("expires").asInstant(); - this.notBefore = json.get("notBefore").asInstant(); - this.notAfter = json.get("notAfter").asInstant(); - this.finalizeUrl = json.get("finalize").asURL(); - - URL certUrl = json.get("certificate").asURL(); - certificate = certUrl != null ? Certificate.bind(getSession(), certUrl) : null; - - this.error = json.get("error").asProblem(getLocation()); - - this.identifiers = json.get("identifiers").asArray().stream() - .map(JSON.Value::asObject) - .map(it -> it.get("value").asString()) - .collect(toList()); - - this.authorizations = json.get("authorizations").asArray().stream() - .map(JSON.Value::asURL) - .collect(toList()); - - loaded = true; + invalidate(); } } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java b/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java index 1ff98d42..9fa84e66 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java @@ -26,7 +26,6 @@ import java.util.Set; import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.connector.Resource; import org.shredzone.acme4j.exception.AcmeException; -import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSONBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -150,10 +149,8 @@ public class OrderBuilder { conn.sendSignedRequest(session.resourceUrl(Resource.NEW_ORDER), claims, session, HttpURLConnection.HTTP_CREATED); - JSON json = conn.readJsonResponse(); - Order order = new Order(session, conn.getLocation()); - order.unmarshal(json); + order.setJSON(conn.readJsonResponse()); return order; } } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Session.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Session.java index 5a539cbf..4a684441 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Session.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Session.java @@ -186,7 +186,7 @@ public class Session { challenge = new Challenge(this); } } - challenge.unmarshall(data); + challenge.setJSON(data); return challenge; } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java index 57b6186b..2c4f80d8 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java @@ -21,14 +21,13 @@ import java.util.Collections; import java.util.List; import java.util.Objects; -import org.shredzone.acme4j.AcmeResource; +import org.shredzone.acme4j.AcmeJsonResource; import org.shredzone.acme4j.Problem; import org.shredzone.acme4j.Session; import org.shredzone.acme4j.Status; import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeProtocolException; -import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSON.Array; import org.shredzone.acme4j.toolbox.JSONBuilder; @@ -44,7 +43,7 @@ import org.slf4j.LoggerFactory; * own type. {@link Challenge#respond(JSONBuilder)} should be overridden to put all * required data to the response. */ -public class Challenge extends AcmeResource { +public class Challenge extends AcmeJsonResource { private static final long serialVersionUID = 2338794776848388099L; private static final Logger LOG = LoggerFactory.getLogger(Challenge.class); @@ -54,8 +53,6 @@ public class Challenge extends AcmeResource { protected static final String KEY_VALIDATED = "validated"; protected static final String KEY_ERRORS = "errors"; - private JSON data = JSON.empty(); - /** * Creates a new generic {@link Challenge} object. * @@ -97,29 +94,21 @@ public class Challenge extends AcmeResource { * Returns the challenge type by name (e.g. "http-01"). */ public String getType() { - return data.get(KEY_TYPE).asString(); - } - - /** - * Returns the location {@link URL} of the challenge. - */ - @Override - public URL getLocation() { - return data.get(KEY_URL).asURL(); + return getJSON().get(KEY_TYPE).asString(); } /** * Returns the current status of the challenge. */ public Status getStatus() { - return data.get(KEY_STATUS).asStatusOrElse(Status.UNKNOWN); + return getJSON().get(KEY_STATUS).asStatusOrElse(Status.UNKNOWN); } /** * Returns the validation date, if returned by the server. */ public Instant getValidated() { - return data.get(KEY_VALIDATED).asInstant(); + return getJSON().get(KEY_VALIDATED).asInstant(); } /** @@ -128,9 +117,11 @@ public class Challenge extends AcmeResource { */ public List getErrors() { URL location = getLocation(); - return Collections.unmodifiableList(data.get(KEY_ERRORS).asArray().stream() - .map(it -> it.asProblem(location)) - .collect(toList())); + return Collections.unmodifiableList(getJSON().get(KEY_ERRORS) + .asArray() + .stream() + .map(it -> it.asProblem(location)) + .collect(toList())); } /** @@ -138,7 +129,7 @@ public class Challenge extends AcmeResource { * {@code null} if there are no errors. */ public Problem getLastError() { - Array errors = data.get(KEY_ERRORS).asArray(); + Array errors = getJSON().get(KEY_ERRORS).asArray(); if (!errors.isEmpty()) { return errors.get(errors.size() - 1).asProblem(getLocation()); } else { @@ -146,13 +137,6 @@ public class Challenge extends AcmeResource { } } - /** - * Returns the JSON representation of the challenge data. - */ - protected JSON getJSON() { - return data; - } - /** * Exports the response state, as preparation for triggering the challenge. * @@ -174,13 +158,8 @@ public class Challenge extends AcmeResource { return type != null && !type.trim().isEmpty(); } - /** - * Sets the challenge state to the given JSON map. - * - * @param json - * JSON containing the challenge data - */ - public void unmarshall(JSON json) { + @Override + public void setJSON(JSON json) { String type = json.get(KEY_TYPE).asString(); if (type == null) { throw new IllegalArgumentException("map does not contain a type"); @@ -189,7 +168,9 @@ public class Challenge extends AcmeResource { throw new AcmeProtocolException("wrong type: " + type); } - data = json; + setLocation(json.get(KEY_URL).asURL()); + + super.setJSON(json); } /** @@ -208,28 +189,7 @@ public class Challenge extends AcmeResource { conn.sendSignedRequest(getLocation(), claims, getSession()); - unmarshall(conn.readJsonResponse()); - } - } - - /** - * Updates the state of this challenge. - * - * @throws AcmeRetryAfterException - * the challenge is still being validated, and the server returned an - * estimated date when the challenge will be completed. If you are polling - * for the challenge to complete, you should wait for the date given in - * {@link AcmeRetryAfterException#getRetryAfter()}. Note that the - * challenge status is updated even if this exception was thrown. - */ - public void update() throws AcmeException { - LOG.debug("update"); - try (Connection conn = getSession().provider().connect()) { - conn.sendRequest(getLocation(), getSession()); - - unmarshall(conn.readJsonResponse()); - - conn.handleRetryAfter("challenge is not completed yet"); + setJSON(conn.readJsonResponse()); } } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java index e44d6803..37ded4e5 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java @@ -433,6 +433,7 @@ public class AccountTest { }; Account account = new Account(provider.createSession(), locationUrl); + account.setJSON(getJSON("newAccount")); EditableAccount editable = account.modify(); assertThat(editable, notNullValue()); @@ -442,9 +443,10 @@ public class AccountTest { editable.commit(); assertThat(account.getLocation(), is(locationUrl)); - assertThat(account.getContacts().size(), is(2)); - assertThat(account.getContacts().get(0), is(URI.create("mailto:foo2@example.com"))); - assertThat(account.getContacts().get(1), is(URI.create("mailto:foo3@example.com"))); + assertThat(account.getContacts().size(), is(3)); + assertThat(account.getContacts().get(0), is(URI.create("mailto:foo@example.com"))); + assertThat(account.getContacts().get(1), is(URI.create("mailto:foo2@example.com"))); + assertThat(account.getContacts().get(2), is(URI.create("mailto:foo3@example.com"))); provider.close(); } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/AcmeJsonResourceTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/AcmeJsonResourceTest.java new file mode 100644 index 00000000..f4c54d00 --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AcmeJsonResourceTest.java @@ -0,0 +1,154 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2018 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j; + +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; +import static org.shredzone.acme4j.toolbox.TestUtils.getJSON; + +import org.junit.Test; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.toolbox.JSON; +import org.shredzone.acme4j.toolbox.TestUtils; + +/** + * Unit tests for {@link AcmeJsonResource}. + */ +public class AcmeJsonResourceTest { + + private static final JSON JSON_DATA = getJSON("newAccountResponse"); + + /** + * Test {@link AcmeJsonResource#AcmeJsonResource(Session)}. + */ + @Test + public void testSessionConstructor() throws Exception { + Session session = TestUtils.session(); + + AcmeJsonResource resource = new DummyJsonResource(session); + assertThat(resource.getSession(), is(session)); + assertThat(resource.isValid(), is(false)); + assertUpdateInvoked(resource, 0); + + assertThat(resource.getJSON(), is(JSON_DATA)); + assertThat(resource.isValid(), is(true)); + assertUpdateInvoked(resource, 1); + } + + /** + * Test {@link AcmeJsonResource#AcmeJsonResource(Session, JSON)}. + */ + @Test + public void testSessionAndJsonConstructor() throws Exception { + Session session = TestUtils.session(); + + AcmeJsonResource resource = new DummyJsonResource(session, JSON_DATA); + assertThat(resource.getSession(), is(session)); + assertThat(resource.isValid(), is(true)); + assertThat(resource.getJSON(), is(JSON_DATA)); + assertUpdateInvoked(resource, 0); + } + + /** + * Test {@link AcmeJsonResource#setJSON(JSON)}. + */ + @Test + public void testSetJson() throws Exception { + Session session = TestUtils.session(); + + JSON jsonData2 = getJSON("requestOrderResponse"); + + AcmeJsonResource resource = new DummyJsonResource(session); + assertThat(resource.isValid(), is(false)); + assertUpdateInvoked(resource, 0); + + resource.setJSON(JSON_DATA); + assertThat(resource.getJSON(), is(JSON_DATA)); + assertThat(resource.isValid(), is(true)); + assertUpdateInvoked(resource, 0); + + resource.setJSON(jsonData2); + assertThat(resource.getJSON(), is(jsonData2)); + assertThat(resource.isValid(), is(true)); + assertUpdateInvoked(resource, 0); + } + + /** + * Test {@link AcmeJsonResource#invalidate()}. + */ + @Test + public void testInvalidate() throws Exception { + Session session = TestUtils.session(); + + AcmeJsonResource resource = new DummyJsonResource(session, JSON_DATA); + assertThat(resource.isValid(), is(true)); + assertUpdateInvoked(resource, 0); + + resource.invalidate(); + assertThat(resource.isValid(), is(false)); + assertUpdateInvoked(resource, 0); + + resource.setJSON(JSON_DATA); + assertThat(resource.isValid(), is(true)); + assertUpdateInvoked(resource, 0); + + resource.invalidate(); + assertThat(resource.isValid(), is(false)); + assertUpdateInvoked(resource, 0); + + assertThat(resource.getJSON(), is(JSON_DATA)); + assertThat(resource.isValid(), is(true)); + assertUpdateInvoked(resource, 1); + } + + /** + * Assert that {@link AcmeJsonResource#update()} has been invoked a given number of + * times. + * + * @param resource + * {@link AcmeJsonResource} to test + * @param count + * Expected number of times + */ + private static void assertUpdateInvoked(AcmeJsonResource resource, int count) { + DummyJsonResource dummy = (DummyJsonResource) resource; + assertThat("update counter", dummy.updateCount, is(count)); + } + + /** + * Minimum implementation of {@link AcmeJsonResource}. + */ + private static class DummyJsonResource extends AcmeJsonResource { + private static final long serialVersionUID = -6459238185161771948L; + + private int updateCount = 0; + + public DummyJsonResource(Session session) { + super(session); + } + + public DummyJsonResource(Session session, JSON json) { + super(session, json); + } + + @Override + public void update() throws AcmeException { + // update() is tested individually in all AcmeJsonResource subclasses. + // Here we just simulate the update, by setting a JSON. + updateCount++; + setJSON(JSON_DATA); + } + } + +} diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java index 4d55c615..45c69967 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java @@ -273,7 +273,7 @@ public class AuthorizationTest { provider.putTestChallenge(DUPLICATE_TYPE, new Challenge(session)); Authorization authorization = new Authorization(session, locationUrl); - authorization.unmarshalAuthorization(getJSON("authorizationChallenges")); + authorization.setJSON(getJSON("authorizationChallenges")); return authorization; } } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java index 96d29559..61b23d40 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java @@ -26,6 +26,7 @@ import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.Test; +import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.provider.TestableConnectionProvider; import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSONBuilder; @@ -54,6 +55,11 @@ public class OrderTest { public JSON readJsonResponse() { return getJSON("updateOrderResponse"); } + + @Override + public void handleRetryAfter(String message) throws AcmeException { + assertThat(message, not(nullValue())); + } }; Session session = provider.createSession(); @@ -103,6 +109,11 @@ public class OrderTest { public JSON readJsonResponse() { return getJSON("updateOrderResponse"); } + + @Override + public void handleRetryAfter(String message) throws AcmeException { + assertThat(message, not(nullValue())); + } }; Session session = provider.createSession(); @@ -153,6 +164,11 @@ public class OrderTest { public JSON readJsonResponse() { return getJSON(isFinalized ? "finalizeResponse" : "updateOrderResponse"); } + + @Override + public void handleRetryAfter(String message) throws AcmeException { + assertThat(message, not(nullValue())); + } }; Session session = provider.createSession(); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java index f01130ee..6f7222e6 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java @@ -87,20 +87,12 @@ public class ChallengeTest { } /** - * Test that after unmarshalling, the challenge properties are set correctly. + * Test that after unmarshaling, the challenge properties are set correctly. */ @Test - public void testUnmarshall() throws URISyntaxException { + public void testUnmarshal() throws URISyntaxException { Challenge challenge = new Challenge(session); - - // Test default values - assertThat(challenge.getType(), is(nullValue())); - assertThat(challenge.getStatus(), is(Status.UNKNOWN)); - assertThat(challenge.getLocation(), is(nullValue())); - assertThat(challenge.getValidated(), is(nullValue())); - - // Unmarshall a challenge JSON - challenge.unmarshall(getJSON("genericChallenge")); + challenge.setJSON(getJSON("genericChallenge")); // Test unmarshalled values assertThat(challenge.getType(), is("generic-01")); @@ -135,7 +127,7 @@ public class ChallengeTest { @Test public void testRespond() throws JoseException { Challenge challenge = new Challenge(session); - challenge.unmarshall(getJSON("genericChallenge")); + challenge.setJSON(getJSON("genericChallenge")); JSONBuilder cb = new JSONBuilder(); challenge.respond(cb); @@ -149,7 +141,7 @@ public class ChallengeTest { @Test(expected = AcmeProtocolException.class) public void testNotAcceptable() throws URISyntaxException { Http01Challenge challenge = new Http01Challenge(session); - challenge.unmarshall(getJSON("dnsChallenge")); + challenge.setJSON(getJSON("dnsChallenge")); } /** @@ -176,7 +168,7 @@ public class ChallengeTest { Session session = provider.createSession(); Http01Challenge challenge = new Http01Challenge(session); - challenge.unmarshall(getJSON("triggerHttpChallenge")); + challenge.setJSON(getJSON("triggerHttpChallenge")); challenge.trigger(); @@ -211,7 +203,7 @@ public class ChallengeTest { Session session = provider.createSession(); Challenge challenge = new Http01Challenge(session); - challenge.unmarshall(getJSON("triggerHttpChallengeResponse")); + challenge.setJSON(getJSON("triggerHttpChallengeResponse")); challenge.update(); @@ -249,7 +241,7 @@ public class ChallengeTest { Session session = provider.createSession(); Challenge challenge = new Http01Challenge(session); - challenge.unmarshall(getJSON("triggerHttpChallengeResponse")); + challenge.setJSON(getJSON("triggerHttpChallengeResponse")); try { challenge.update(); @@ -313,7 +305,7 @@ public class ChallengeTest { @Test(expected = IllegalArgumentException.class) public void testBadUnmarshall() { Challenge challenge = new Challenge(session); - challenge.unmarshall(getJSON("updateAccountResponse")); + challenge.setJSON(getJSON("updateAccountResponse")); } } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/DnsChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/DnsChallengeTest.java index f41cba97..3b2d273f 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/DnsChallengeTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/DnsChallengeTest.java @@ -47,7 +47,7 @@ public class DnsChallengeTest { @Test public void testDnsChallenge() throws IOException { Dns01Challenge challenge = new Dns01Challenge(session); - challenge.unmarshall(getJSON("dnsChallenge")); + challenge.setJSON(getJSON("dnsChallenge")); assertThat(challenge.getType(), is(Dns01Challenge.TYPE)); assertThat(challenge.getStatus(), is(Status.PENDING)); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/HttpChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/HttpChallengeTest.java index d3f5fbb5..0e4695c6 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/HttpChallengeTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/HttpChallengeTest.java @@ -50,7 +50,7 @@ public class HttpChallengeTest { @Test public void testHttpChallenge() throws IOException { Http01Challenge challenge = new Http01Challenge(session); - challenge.unmarshall(getJSON("httpChallenge")); + challenge.setJSON(getJSON("httpChallenge")); assertThat(challenge.getType(), is(Http01Challenge.TYPE)); assertThat(challenge.getStatus(), is(Status.PENDING)); @@ -70,7 +70,7 @@ public class HttpChallengeTest { @Test(expected = AcmeProtocolException.class) public void testNoTokenSet() { Http01Challenge challenge = new Http01Challenge(session); - challenge.unmarshall(getJSON("httpNoTokenChallenge")); + challenge.setJSON(getJSON("httpNoTokenChallenge")); challenge.getToken(); } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSni02ChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSni02ChallengeTest.java index eb69367b..1937b2e8 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSni02ChallengeTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSni02ChallengeTest.java @@ -47,7 +47,7 @@ public class TlsSni02ChallengeTest { @Test public void testTlsSni02Challenge() throws IOException { TlsSni02Challenge challenge = new TlsSni02Challenge(session); - challenge.unmarshall(getJSON("tlsSni02Challenge")); + challenge.setJSON(getJSON("tlsSni02Challenge")); assertThat(challenge.getType(), is(TlsSni02Challenge.TYPE)); assertThat(challenge.getStatus(), is(Status.PENDING)); diff --git a/acme4j-client/src/test/resources/json/dnsChallenge.json b/acme4j-client/src/test/resources/json/dnsChallenge.json index 361698b7..3acc82db 100644 --- a/acme4j-client/src/test/resources/json/dnsChallenge.json +++ b/acme4j-client/src/test/resources/json/dnsChallenge.json @@ -1,5 +1,6 @@ { "type": "dns-01", + "url": "https://example.com/acme/authz/0", "status": "pending", "token": "pNvmJivs0WCko2suV7fhe-59oFqyYx_yB7tx6kIMAyE" } diff --git a/acme4j-client/src/test/resources/json/httpChallenge.json b/acme4j-client/src/test/resources/json/httpChallenge.json index 7431e9f5..589b5e29 100644 --- a/acme4j-client/src/test/resources/json/httpChallenge.json +++ b/acme4j-client/src/test/resources/json/httpChallenge.json @@ -1,5 +1,6 @@ { "type": "http-01", + "url": "https://example.com/acme/authz/0", "status": "pending", "token": "rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ" } diff --git a/acme4j-client/src/test/resources/json/httpNoTokenChallenge.json b/acme4j-client/src/test/resources/json/httpNoTokenChallenge.json index c6685a2f..12be94d1 100644 --- a/acme4j-client/src/test/resources/json/httpNoTokenChallenge.json +++ b/acme4j-client/src/test/resources/json/httpNoTokenChallenge.json @@ -1,4 +1,5 @@ { "type": "http-01", + "url": "https://example.com/acme/authz/0", "status": "pending" } diff --git a/acme4j-client/src/test/resources/json/modifyAccount.json b/acme4j-client/src/test/resources/json/modifyAccount.json index 2ea857d0..803b4e49 100644 --- a/acme4j-client/src/test/resources/json/modifyAccount.json +++ b/acme4j-client/src/test/resources/json/modifyAccount.json @@ -1,5 +1,6 @@ { "contact": [ + "mailto:foo@example.com", "mailto:foo2@example.com", "mailto:foo3@example.com" ] diff --git a/acme4j-client/src/test/resources/json/modifyAccountResponse.json b/acme4j-client/src/test/resources/json/modifyAccountResponse.json index 9520f59c..981bc6c3 100644 --- a/acme4j-client/src/test/resources/json/modifyAccountResponse.json +++ b/acme4j-client/src/test/resources/json/modifyAccountResponse.json @@ -1,6 +1,7 @@ { "termsOfServiceAgreed": true, "contact": [ + "mailto:foo@example.com", "mailto:foo2@example.com", "mailto:foo3@example.com" ] diff --git a/acme4j-client/src/test/resources/json/tlsSni02Challenge.json b/acme4j-client/src/test/resources/json/tlsSni02Challenge.json index 750c7f92..1308df7b 100644 --- a/acme4j-client/src/test/resources/json/tlsSni02Challenge.json +++ b/acme4j-client/src/test/resources/json/tlsSni02Challenge.json @@ -1,5 +1,6 @@ { "type": "tls-sni-02", + "url": "https://example.com/acme/authz/0", "status": "pending", "token": "VNLBdSiZ3LppU2CRG8bilqlwq4DuApJMg3ZJowU6JhQ" }