request) {
+
+ final var builder = GetStatementsRequest.builder();
+
+ request.accept(builder);
+
+ return getStatementIterator(builder.build());
+
+ }
+
+ /**
+ * Gets all of the Statements as a {@link StatementIterator}.
+ *
+ * This method loads ALL of Statements which fullfills the request filters from the LRS
+ * dynamically. (It sends additional
+ * {@link XapiClient#getMoreStatements(java.util.function.Consumer)} request if all the previously
+ * loaded Statements were processed from the iterator.)
+ *
+ *
+ * @return a {@link StatementIterator} object as a {@link Mono}.
+ */
+ public Mono getStatementIterator() {
+
+ return getStatementIterator(r -> {
+ });
+
+ }
+
+ /**
+ *
+ * Voids a {@link Statement}.
+ *
+ * The Actor of the voiding statement will be the same as the Actor of the target Statement.
+ *
+ * The returned ResponseEntity contains the response headers and the Statement identifier of the
+ * generated voiding Statement.
+ *
+ *
+ * @param targetStatement The {@link Statement} to be voided
+ *
+ * @return the ResponseEntity
+ */
+ public Mono> voidStatement(Statement targetStatement) {
+ return voidStatement(targetStatement, targetStatement.getActor());
+ }
+
+ /**
+ * Voids a {@link Statement}.
+ *
+ * The returned ResponseEntity contains the response headers and the Statement identifier of the
+ * generated voiding Statement.
+ *
+ *
+ * @param targetStatement The {@link Statement} to be voided
+ * @param actor the Actor of the voiding Statement
+ *
+ * @return the ResponseEntity
+ */
+ public Mono> voidStatement(Statement targetStatement, Actor actor) {
+ return voidStatement(targetStatement.getId(), actor);
+ }
+
+ /**
+ * Voids a {@link Statement}.
+ *
+ * The returned ResponseEntity contains the response headers and the Statement identifier of the
+ * generated voiding Statement.
+ *
+ *
+ * @param targetStatementId The id of the {@link Statement} to be voided
+ * @param actor the Actor of the voiding Statement
+ *
+ * @return the ResponseEntity
+ */
+ public Mono> voidStatement(UUID targetStatementId, Actor actor) {
+ Assert.notNull(targetStatementId, "Target Statement id cannot be null");
+ Assert.notNull(actor, "Actor cannot be null");
+
+ return postStatement(r -> r
+
+ .statement(s -> s
+
+ .actor(actor)
+
+ .verb(Verb.VOIDED)
+
+ .statementReferenceObject(o -> o
+
+ .id(targetStatementId)
+
+ )
+
+ )
+
+ );
+
+ }
+
+ /**
+ *
+ * StatementIterator.
+ *
+ * Iterates through the Statements of the result of a
+ * {@link XapiClient#getStatements(GetStatementsRequest)}. If more Statements are available it
+ * automatically loads them from the server.
+ *
+ * @author István Rátkai (Selindek)
+ */
+ @RequiredArgsConstructor
+ public class StatementIterator implements Iterator {
+
+ private URI more;
+ private Iterator statements;
+
+ private StatementIterator(ResponseEntity response) {
+ init(response);
+ }
+
+ /**
+ * Convenient method for transforming this StatementIterator to a {@link Stream}.
+ *
+ * @return a {@link Stream} of {@link Statement}s
+ */
+ public Stream toStream() {
+ final Iterable iterable = () -> this;
+ return StreamSupport.stream(iterable.spliterator(), false);
+ }
+
+ private void init(ResponseEntity response) {
+ final var statementResult = response.getBody();
+ more = statementResult.hasMore() ? statementResult.getMore() : null;
+ final var s = statementResult.getStatements();
+ statements = s == null ? Collections.emptyIterator() : s.iterator();
+ }
+
+ @Override
+ public boolean hasNext() {
+ return statements.hasNext() || more != null;
+ }
+
+ @Override
+ public Statement next() {
+ if (!statements.hasNext()) {
+ if (more == null) {
+ throw new NoSuchElementException();
+ }
+ init(getMoreStatements(r -> r.more(more)).block());
+ }
+ return statements.next();
+ }
+
+ }
}
diff --git a/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientTests.java b/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientTests.java
index dcc9ebff..85f4252d 100644
--- a/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientTests.java
+++ b/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientTests.java
@@ -3,6 +3,7 @@
*/
package dev.learning.xapi.client;
+import static org.hamcrest.CoreMatchers.nullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.core.Is.is;
import static org.hamcrest.core.IsInstanceOf.instanceOf;
@@ -21,7 +22,9 @@
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
+import java.util.NoSuchElementException;
import java.util.UUID;
+import java.util.concurrent.TimeUnit;
import lombok.Getter;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
@@ -515,7 +518,7 @@ void whenGettingStatementsWithAllParametersThenPathIsExpected() throws Interrupt
.agent(a -> a.name("A N Other").mbox("mailto:another@example.com"))
- .verb("http://adlnet.gov/expapi/verbs/answered")
+ .verb(Verb.ANSWERED)
.activity("https://example.com/activity/1")
@@ -2243,6 +2246,270 @@ void whenGettingActivityProfilesWithoutSinceParameterThenPathIsExpected()
is("/activities/profile?activityId=https%3A%2F%2Fexample.com%2Factivity%2F1"));
}
+ @Test
+ void whenGettingStatementIteratorViaMultipeResponsesThenResultIsExpected()
+ throws InterruptedException {
+ final var body1 = """
+ {
+ "statements" : [
+ {
+ "id" : "c0aaea0b-252b-4d9d-b7ad-46c541572570"
+ }
+ ],
+ "more" : "/statements/more/1"
+ }
+ """;
+ final var body2 = """
+ {
+ "statements" : [
+ {
+ "id" : "4ed0209a-f50f-4f57-8602-ba5f981d211a"
+ }
+ ],
+ "more" : ""
+ }
+ """;
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK").setBody(body1)
+ .addHeader("Content-Type", "application/json; charset=utf-8"));
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK").setBody(body2)
+ .addHeader("Content-Type", "application/json; charset=utf-8"));
+
+ // When Getting StatementIterator Via Multipe Responses
+ final var iterator = client.getStatementIterator().block();
+
+ // Then Result Is Expected
+ assertThat(iterator.hasNext(), is(true));
+ assertThat(iterator.next().getId(),
+ is(UUID.fromString("c0aaea0b-252b-4d9d-b7ad-46c541572570")));
+ assertThat(iterator.hasNext(), is(true));
+ assertThat(iterator.next().getId(),
+ is(UUID.fromString("4ed0209a-f50f-4f57-8602-ba5f981d211a")));
+ assertThat(iterator.hasNext(), is(false));
+
+ }
+
+ @Test
+ void whenGettingStatementIteratorViaMultipeResponsesThenRequestsAreExpected()
+ throws InterruptedException {
+ final var body1 = """
+ {
+ "statements" : [
+ {
+ "id" : "c0aaea0b-252b-4d9d-b7ad-46c541572570"
+ }
+ ],
+ "more" : "/statements/more/1"
+ }
+ """;
+ final var body2 = """
+ {
+ "statements" : [
+ {
+ "id" : "4ed0209a-f50f-4f57-8602-ba5f981d211a"
+ }
+ ],
+ "more" : ""
+ }
+ """;
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK").setBody(body1)
+ .addHeader("Content-Type", "application/json; charset=utf-8"));
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK").setBody(body2)
+ .addHeader("Content-Type", "application/json; charset=utf-8"));
+
+ // When Getting StatementIterator Via Multipe Responses
+ final var iterator = client.getStatementIterator().block();
+ iterator.next();
+ iterator.next();
+
+ // Then Requests Are Expected
+ assertThat(mockWebServer.takeRequest().getPath(), is("/statements"));
+ assertThat(mockWebServer.takeRequest().getPath(), is("/statements/more/1"));
+
+ }
+
+ @Test
+ void whenGettingStatementIteratorThenRequestsAreExpected() throws InterruptedException {
+ final var body1 = """
+ {
+ "statements" : [
+ {
+ "id" : "c0aaea0b-252b-4d9d-b7ad-46c541572570"
+ }
+ ],
+ "more" : "/statements/more/1"
+ }
+ """;
+ final var body2 = """
+ {
+ "statements" : [
+ {
+ "id" : "4ed0209a-f50f-4f57-8602-ba5f981d211a"
+ }
+ ],
+ "more" : ""
+ }
+ """;
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK").setBody(body1)
+ .addHeader("Content-Type", "application/json; charset=utf-8"));
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK").setBody(body2)
+ .addHeader("Content-Type", "application/json; charset=utf-8"));
+
+ // When Getting StatementIterator
+ client.getStatementIterator().block();
+
+ // Then Requests Are Expected
+ assertThat(mockWebServer.takeRequest().getPath(), is("/statements"));
+ assertThat(mockWebServer.takeRequest(1, TimeUnit.SECONDS), is(nullValue()));
+
+ }
+
+ @Test
+ void whenGettingStatementIteratorAndProcessingItAsStreamThenRequestsAreExpected()
+ throws InterruptedException {
+ final var body1 = """
+ {
+ "statements" : [
+ {
+ "id" : "c0aaea0b-252b-4d9d-b7ad-46c541572570"
+ },
+ {
+ "id" : "940a3f5c-1f31-47c7-82fc-5979e2786c02"
+ }
+ ],
+ "more" : "/statements/more/1"
+ }
+ """;
+ final var body2 = """
+ {
+ "statements" : [
+ {
+ "id" : "4ed0209a-f50f-4f57-8602-ba5f981d211a"
+ }
+ ],
+ "more" : ""
+ }
+ """;
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK").setBody(body1)
+ .addHeader("Content-Type", "application/json; charset=utf-8"));
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK").setBody(body2)
+ .addHeader("Content-Type", "application/json; charset=utf-8"));
+
+ // When Getting StatementIterator
+ final var iterator = client.getStatementIterator().block();
+
+ // And Processing it As Stream
+ iterator.toStream().limit(1).forEach(s -> {
+ });
+
+ // Then Requests Are Expected
+ assertThat(mockWebServer.takeRequest().getPath(), is("/statements"));
+ assertThat(mockWebServer.takeRequest(1, TimeUnit.SECONDS), is(nullValue()));
+
+ }
+
+ @Test
+ void givenEmptyStatementResultWhenGettingStatementIteratorThenHasNextIsFalse()
+ throws InterruptedException {
+
+ // Given Empty StatementResult
+ final var body = """
+ {
+ "statements" : [
+ ],
+ "more" : ""
+ }
+ """;
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK").setBody(body)
+ .addHeader("Content-Type", "application/json; charset=utf-8"));
+
+ // When Getting StatementIterator
+ final var iterator = client.getStatementIterator().block();
+
+ // Then HasNext Is False
+ assertThat(iterator.hasNext(), is(false));
+
+ }
+
+ @Test
+ void givenEmptyResponseWhenGettingStatementIteratorThenHasNextIsFalse()
+ throws InterruptedException {
+
+ // Given Empty Response
+ // This response is technically invalid by the xAPI specification, but we cannot assume
+ // conformance.
+ // conformance of the commercial LRSs.
+ final var body = "{}";
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK").setBody(body)
+ .addHeader("Content-Type", "application/json; charset=utf-8"));
+
+ // When Getting StatementIterator
+ final var iterator = client.getStatementIterator().block();
+
+ // Then HasNext Is False
+ assertThat(iterator.hasNext(), is(false));
+
+ }
+
+ @Test
+ void givenEmptyResponseWhenGettingStatementIteratorThenNextThrowsAnException()
+ throws InterruptedException {
+
+ // Given Empty Response
+ final var body = """
+ {
+ "statements" : [
+ ],
+ "more" : ""
+ }
+ """;
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK").setBody(body)
+ .addHeader("Content-Type", "application/json; charset=utf-8"));
+
+ // When Getting StatementIterator
+ final var iterator = client.getStatementIterator().block();
+
+ // Then Next Throws An Exception
+ assertThrows(NoSuchElementException.class, () -> iterator.next());
+
+ }
+
+ @Test
+ void whenVoidingStatementThenBodyIsExpected() throws InterruptedException {
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK")
+ .setBody("[\"2eb84e56-441a-492c-9d7b-f8e9ddd3e15d\"]")
+ .addHeader("Content-Type", "application/json"));
+
+ final var attemptedStatement = Statement.builder()
+
+ .id(UUID.fromString("175c9264-692f-4108-9b7d-0ba64bd59ac3"))
+
+ .agentActor(a -> a.name("A N Other").mbox("mailto:another@example.com"))
+
+ .verb(Verb.ATTEMPTED)
+
+ .activityObject(o -> o.id("https://example.com/activity/simplestatement")
+ .definition(d -> d.addName(Locale.ENGLISH, "Simple Statement")))
+
+ .build();
+
+ // When Voiding Statement
+ client.voidStatement(attemptedStatement).block();
+
+ final var recordedRequest = mockWebServer.takeRequest();
+
+ // Then Body Is Expected
+ assertThat(recordedRequest.getBody().readUtf8(), is(
+ "{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/voided\",\"display\":{\"und\":\"voided\"}},\"object\":{\"objectType\":\"StatementRef\",\"id\":\"175c9264-692f-4108-9b7d-0ba64bd59ac3\"}}"));
+ }
+
@Getter
private static class SamplePerson {