diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java index 8acc48cc..8a0737ef 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java @@ -23,6 +23,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Iterator; import java.util.List; import java.util.Map; @@ -31,6 +32,7 @@ import org.jose4j.jws.JsonWebSignature; import org.jose4j.lang.JoseException; 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.AcmeNetworkException; import org.shredzone.acme4j.exception.AcmeProtocolException; @@ -50,6 +52,8 @@ public class Registration extends AcmeResource { private final List contacts = new ArrayList<>(); private URI agreement; + private URI authorizations; + private URI certificates; private Status status; /** @@ -96,6 +100,48 @@ public class Registration extends AcmeResource { return status; } + /** + * Returns an {@link Iterator} of all {@link Authorization} belonging to this + * {@link Registration}. + *

+ * Using the iterator will initiate one or more requests to the ACME server. + * + * @return {@link Iterator} instance that returns {@link Authorization} objects. + * {@link Iterator#hasNext()} and {@link Iterator#next()} may throw + * {@link AcmeProtocolException} if a batch of authorization URIs could not be + * fetched from the server. + */ + public Iterator getAuthorizations() throws AcmeException { + LOG.debug("getAuthorizations"); + return new ResourceIterator(getSession(), "authorizations", authorizations) { + @Override + protected Authorization create(Session session, URI uri) { + return Authorization.bind(session, uri); + } + }; + } + + /** + * Returns an {@link Iterator} of all {@link Certificate} belonging to this + * {@link Registration}. + *

+ * Using the iterator will initiate one or more requests to the ACME server. + * + * @return {@link Iterator} instance that returns {@link Certificate} objects. + * {@link Iterator#hasNext()} and {@link Iterator#next()} may throw + * {@link AcmeProtocolException} if a batch of certificate URIs could not be + * fetched from the server. + */ + public Iterator getCertificates() throws AcmeException { + LOG.debug("getCertificates"); + return new ResourceIterator(getSession(), "certificates", certificates) { + @Override + protected Certificate create(Session session, URI uri) { + return Certificate.bind(session, uri); + } + }; + } + /** * Updates the registration to the current account status. */ @@ -294,6 +340,26 @@ public class Registration extends AcmeResource { } } + if (json.containsKey("authorizations")) { + try { + this.authorizations = new URI((String) json.get("authorizations")); + } catch (ClassCastException | URISyntaxException ex) { + throw new AcmeProtocolException("Illegal authorizations URI", ex); + } + } else { + this.authorizations = null; + } + + if (json.containsKey("certificates")) { + try { + this.certificates = new URI((String) json.get("certificates")); + } catch (ClassCastException | URISyntaxException ex) { + throw new AcmeProtocolException("Illegal certificates URI", ex); + } + } else { + this.certificates = null; + } + if (json.containsKey("status")) { this.status = Status.parse((String) json.get("status")); } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/ResourceIterator.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/ResourceIterator.java new file mode 100644 index 00000000..7d9927f4 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/ResourceIterator.java @@ -0,0 +1,177 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2016 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.connector; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayDeque; +import java.util.Collection; +import java.util.Deque; +import java.util.Iterator; +import java.util.Map; +import java.util.NoSuchElementException; + +import org.shredzone.acme4j.AcmeResource; +import org.shredzone.acme4j.Session; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.exception.AcmeNetworkException; +import org.shredzone.acme4j.exception.AcmeProtocolException; + +/** + * An {@link Iterator} that fetches a batch of URIs from the ACME server, and + * generates {@link AcmeResource} instances. + * + * @author Richard "Shred" Körber + */ +public abstract class ResourceIterator implements Iterator { + + private final Session session; + private final String field; + private final Deque uriList = new ArrayDeque<>(); + private boolean eol = false; + private URI nextUri; + + /** + * Creates a new {@link ResourceIterator}. + * + * @param session + * {@link Session} to bind this iterator to + * @param field + * Field name to be used in the JSON response + * @param start + * URI of the first JSON array, may be {@code null} for an empty iterator + */ + public ResourceIterator(Session session, String field, URI start) { + this.session = session; + this.field = field; + this.nextUri = start; + } + + /** + * Checks if there is another object in the result. + * + * @throws AcmeProtocolException + * if the next batch of URIs could not be fetched from the server + */ + @Override + public boolean hasNext() { + if (eol) { + return false; + } + + if (uriList.isEmpty()) { + fetch(); + } + + if (uriList.isEmpty()) { + eol = true; + } + + return !uriList.isEmpty(); + } + + /** + * Returns the next object of the result. + * + * @throws AcmeProtocolException + * if the next batch of URIs could not be fetched from the server + * @throws NoSuchElementException + * if there are no more entries + */ + @Override + public T next() { + if (!eol && uriList.isEmpty()) { + fetch(); + } + + URI next = uriList.poll(); + if (next == null) { + eol = true; + throw new NoSuchElementException("no more " + field); + } + + return create(session, next); + } + + /** + * Unsupported operation, only here to satisfy the {@link Iterator} interface. + */ + @Override + public void remove() { + throw new UnsupportedOperationException("cannot remove " + field); + } + + /** + * Creates a new {@link AcmeResource} object by binding it to the {@link Session} and + * using the given {@link URI}. + * + * @param session + * {@link Session} to bind the object to + * @param uri + * {@link URI} of the resource + * @return Created object + */ + protected abstract T create(Session session, URI uri); + + /** + * Fetches the next batch of URIs. Handles exceptions. Does nothing if there is no + * URI of the next batch. + */ + private void fetch() { + if (nextUri == null) { + return; + } + + try { + readAndQueue(); + } catch (AcmeException ex) { + throw new AcmeProtocolException("failed to read next set of " + field, ex); + } + } + + /** + * Reads the next batch of URIs from the server, and fills the queue with the URIs. If + * there is a "next" header, it is used for the next batch of URIs. + */ + @SuppressWarnings("unchecked") + private void readAndQueue() throws AcmeException { + try (Connection conn = session.provider().connect()) { + int rc = conn.sendRequest(nextUri); + if (rc != HttpURLConnection.HTTP_OK) { + conn.throwAcmeException(); + } + + Map json = conn.readJsonResponse(); + try { + Collection array = (Collection) json.get(field); + if (array != null) { + for (String uri : array) { + uriList.add(new URI(uri)); + } + } + } catch (ClassCastException ex) { + throw new AcmeProtocolException("Expected an array"); + } catch (URISyntaxException ex) { + throw new AcmeProtocolException("Invalid URI", ex); + } + + nextUri = conn.getLink("next"); + } catch (IOException ex) { + throw new AcmeNetworkException(ex); + } + } + +} diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/RegistrationTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/RegistrationTest.java index b8ec95ca..8c38f433 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/RegistrationTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/RegistrationTest.java @@ -24,6 +24,9 @@ import java.net.URI; import java.net.URISyntaxException; import java.security.KeyPair; import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; import java.util.Map; import org.jose4j.jws.JsonWebSignature; @@ -77,17 +80,39 @@ public class RegistrationTest { @Test public void testUpdateRegistration() throws AcmeException, IOException { TestableConnectionProvider provider = new TestableConnectionProvider() { + private Map jsonResponse; + @Override public int sendSignedRequest(URI uri, ClaimBuilder claims, Session session) { assertThat(uri, is(locationUri)); assertThat(claims.toString(), sameJSONAs(getJson("updateRegistration"))); assertThat(session, is(notNullValue())); + jsonResponse = getJsonAsMap("updateRegistrationResponse"); return HttpURLConnection.HTTP_ACCEPTED; } + @Override + public int sendRequest(URI uri) { + if (URI.create("https://example.com/acme/reg/1/authz").equals(uri)) { + jsonResponse = new HashMap<>(); + jsonResponse.put("authorizations", + Arrays.asList("https://example.com/acme/auth/1")); + return HttpURLConnection.HTTP_OK; + } + + if (URI.create("https://example.com/acme/reg/1/cert").equals(uri)) { + jsonResponse = new HashMap<>(); + jsonResponse.put("certificates", + Arrays.asList("https://example.com/acme/cert/1")); + return HttpURLConnection.HTTP_OK; + } + + return HttpURLConnection.HTTP_NOT_FOUND; + } + @Override public Map readJsonResponse() { - return getJsonAsMap("updateRegistrationResponse"); + return jsonResponse; } @Override @@ -99,6 +124,7 @@ public class RegistrationTest { public URI getLink(String relation) { switch(relation) { case "terms-of-service": return agreementUri; + case "next": return null; default: return null; } } @@ -113,6 +139,18 @@ public class RegistrationTest { assertThat(registration.getContacts().get(0), is(URI.create("mailto:foo2@example.com"))); assertThat(registration.getStatus(), is(Status.GOOD)); + Iterator authIt = registration.getAuthorizations(); + assertThat(authIt, not(nullValue())); + assertThat(authIt.next().getLocation(), + is(URI.create("https://example.com/acme/auth/1"))); + assertThat(authIt.hasNext(), is(false)); + + Iterator certIt = registration.getCertificates(); + assertThat(certIt, not(nullValue())); + assertThat(certIt.next().getLocation(), + is(URI.create("https://example.com/acme/cert/1"))); + assertThat(certIt.hasNext(), is(false)); + provider.close(); } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java index 67eb8e11..9149345f 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java @@ -193,8 +193,8 @@ public class SessionTest { final AcmeProvider mockProvider = mock(AcmeProvider.class); when(mockProvider.directory( - org.mockito.Matchers.any(Session.class), - org.mockito.Matchers.eq(serverUri))) + ArgumentMatchers.any(Session.class), + ArgumentMatchers.eq(serverUri))) .thenReturn(TestUtils.getJsonAsMap("directoryNoMeta")); Session session = new Session(serverUri, keyPair) { diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceIteratorTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceIteratorTest.java new file mode 100644 index 00000000..26b3b4e8 --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceIteratorTest.java @@ -0,0 +1,181 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2016 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.connector; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertThat; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; + +import org.jose4j.json.JsonUtil; +import org.jose4j.lang.JoseException; +import org.junit.Before; +import org.junit.Test; +import org.shredzone.acme4j.Authorization; +import org.shredzone.acme4j.Session; +import org.shredzone.acme4j.exception.AcmeProtocolException; +import org.shredzone.acme4j.provider.TestableConnectionProvider; +import org.shredzone.acme4j.util.ClaimBuilder; + +/** + * Unit test for {@link ResourceIterator}. + * + * @author Richard "Shred" Körber + */ +public class ResourceIteratorTest { + + private final int PAGES = 4; + private final int RESOURCES_PER_PAGE = 5; + private final String TYPE = "authorizations"; + + private List resourceURIs = new ArrayList<>(PAGES * RESOURCES_PER_PAGE); + private List pageURIs = new ArrayList<>(PAGES); + + @Before + public void setup() { + resourceURIs.clear(); + for (int ix = 0; ix < RESOURCES_PER_PAGE * PAGES; ix++) { + resourceURIs.add(URI.create("https://example.com/acme/auth/" + ix)); + } + + pageURIs.clear(); + for (int ix = 0; ix < PAGES; ix++) { + pageURIs.add(URI.create("https://example.com/acme/batch/" + ix)); + } + } + + /** + * Test if the {@link ResourceIterator} handles a {@code null} start URI. + */ + @Test(expected = NoSuchElementException.class) + public void nullTest() throws IOException { + Iterator it = createIterator(null); + + assertThat(it, not(nullValue())); + assertThat(it.hasNext(), is(false)); + it.next(); // throws NoSuchElementException + } + + /** + * Test if the {@link ResourceIterator} returns all objects in the correct order. + */ + @Test + public void iteratorTest() throws IOException { + List result = new ArrayList<>(); + + Iterator it = createIterator(pageURIs.get(0)); + while (it.hasNext()) { + result.add(it.next().getLocation()); + } + + assertThat(result, is(equalTo(resourceURIs))); + } + + /** + * Test unusual {@link Iterator#next()} and {@link Iterator#hasNext()} usage. + */ + @Test + public void nextHasNextTest() throws IOException { + List result = new ArrayList<>(); + + Iterator it = createIterator(pageURIs.get(0)); + assertThat(it.hasNext(), is(true)); + assertThat(it.hasNext(), is(true)); + + // don't try this at home, kids... + try { + for (;;) { + result.add(it.next().getLocation()); + } + } catch (NoSuchElementException ex) { + assertThat(it.hasNext(), is(false)); + assertThat(it.hasNext(), is(false)); + } + + assertThat(result, is(equalTo(resourceURIs))); + } + + /** + * Test that {@link Iterator#remove()} fails. + */ + @Test(expected = UnsupportedOperationException.class) + public void removeTest() throws IOException { + Iterator it = createIterator(pageURIs.get(0)); + it.next(); + it.remove(); // throws UnsupportedOperationException + } + + /** + * Creates a new {@link Iterator} of {@link Authorization} objects. + * + * @param first + * URI of the first page + * @return Created {@link Iterator} + */ + private Iterator createIterator(URI first) throws IOException { + TestableConnectionProvider provider = new TestableConnectionProvider() { + private int ix; + + @Override + public int sendRequest(URI uri) { + ix = pageURIs.indexOf(uri); + assertThat(ix, is(greaterThanOrEqualTo(0))); + return HttpURLConnection.HTTP_OK; + } + + @Override + public Map readJsonResponse() { + try { + int start = ix * RESOURCES_PER_PAGE; + int end = (ix + 1) * RESOURCES_PER_PAGE; + + ClaimBuilder cb = new ClaimBuilder(); + cb.array(TYPE, resourceURIs.subList(start, end).toArray()); + + // Make sure to use the JSON parser + return JsonUtil.parseJson(cb.toString()); + } catch (JoseException ex) { + throw new AcmeProtocolException("Invalid JSON", ex); + } + } + + @Override + public URI getLink(String relation) { + if ("next".equals(relation) && (ix + 1 < pageURIs.size())) { + return pageURIs.get(ix + 1); + } + return null; + } + }; + + Session session = provider.createSession(); + + provider.close(); + + return new ResourceIterator(session, TYPE, first) { + @Override + protected Authorization create(Session session, URI uri) { + return Authorization.bind(session, uri); + } + }; + } + +} diff --git a/acme4j-client/src/test/resources/json.properties b/acme4j-client/src/test/resources/json.properties index 1897e3be..6ccc72d2 100644 --- a/acme4j-client/src/test/resources/json.properties +++ b/acme4j-client/src/test/resources/json.properties @@ -54,7 +54,9 @@ updateRegistration = \ updateRegistrationResponse = \ {"agreement":"http://example.com/agreement.pdf",\ "contact":["mailto:foo2@example.com"],\ - "status":"good"} + "status":"good",\ + "authorizations":"https://example.com/acme/reg/1/authz",\ + "certificates":"https://example.com/acme/reg/1/cert"} newAuthorizationRequest = \ {"resource":"new-authz",\