Offer iterators of authorizations and certificates

pull/30/head
Richard Körber 2016-07-21 00:54:27 +02:00
parent 68b7560f2f
commit cef5984f81
6 changed files with 468 additions and 4 deletions

View File

@ -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<URI> 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}.
* <p>
* 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<Authorization> getAuthorizations() throws AcmeException {
LOG.debug("getAuthorizations");
return new ResourceIterator<Authorization>(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}.
* <p>
* 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<Certificate> getCertificates() throws AcmeException {
LOG.debug("getCertificates");
return new ResourceIterator<Certificate>(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"));
}

View File

@ -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<T extends AcmeResource> implements Iterator<T> {
private final Session session;
private final String field;
private final Deque<URI> 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<String, Object> json = conn.readJsonResponse();
try {
Collection<String> array = (Collection<String>) 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);
}
}
}

View File

@ -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<String, Object> 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<String, Object> 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<Authorization> 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<Certificate> 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();
}

View File

@ -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) {

View File

@ -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<URI> resourceURIs = new ArrayList<>(PAGES * RESOURCES_PER_PAGE);
private List<URI> 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<Authorization> 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<URI> result = new ArrayList<>();
Iterator<Authorization> 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<URI> result = new ArrayList<>();
Iterator<Authorization> 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<Authorization> 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<Authorization> 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<String, Object> 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<Authorization>(session, TYPE, first) {
@Override
protected Authorization create(Session session, URI uri) {
return Authorization.bind(session, uri);
}
};
}
}

View File

@ -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",\