diff --git a/README.md b/README.md
index acf2b488..be1db26c 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,26 @@ client.postStatement(
.block();
```
+### Posting a Statement with an attachment
+
+Example:
+
+```java
+client.postStatement(
+ r -> r.statement(s -> s.actor(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")))
+
+ .addAttachment(a -> a.content("Simple attachment").length(17).contentType("text/plain")
+ .usageType(URI.create("https://example.com/attachments/simplestatement"))
+ .addDisplay(Locale.ENGLISH, "text attachment"))
+
+ )).block();
+```
+
### Posting Statements
Example:
diff --git a/samples/pom.xml b/samples/pom.xml
index 95cbb6d8..34cfb6fc 100644
--- a/samples/pom.xml
+++ b/samples/pom.xml
@@ -36,6 +36,7 @@
get-statement
post-statement
+ post-statement-with-attachment
get-statements
get-more-statements
get-voided-statement
diff --git a/samples/post-statement-with-attachment/pom.xml b/samples/post-statement-with-attachment/pom.xml
new file mode 100644
index 00000000..b7115b07
--- /dev/null
+++ b/samples/post-statement-with-attachment/pom.xml
@@ -0,0 +1,22 @@
+
+
+ 4.0.0
+
+ dev.learning.xapi.samples
+ xapi-samples-build
+ 1.1.2-SNAPSHOT
+
+ post-statement-with-attachment
+ Post xAPI Statement With Attachment Sample
+ Post xAPI Statement With Attachment
+
+
+ dev.learning.xapi
+ xapi-client
+
+
+ dev.learning.xapi.samples
+ core
+
+
+
diff --git a/samples/post-statement-with-attachment/src/main/java/dev/learning/xapi/samples/poststatement/PostStatementWithAttachmentApplication.java b/samples/post-statement-with-attachment/src/main/java/dev/learning/xapi/samples/poststatement/PostStatementWithAttachmentApplication.java
new file mode 100644
index 00000000..af2e0740
--- /dev/null
+++ b/samples/post-statement-with-attachment/src/main/java/dev/learning/xapi/samples/poststatement/PostStatementWithAttachmentApplication.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2016-2023 Berry Cloud Ltd. All rights reserved.
+ */
+
+package dev.learning.xapi.samples.poststatement;
+
+import dev.learning.xapi.client.XapiClient;
+import dev.learning.xapi.model.Verb;
+import java.net.URI;
+import java.nio.file.Files;
+import java.util.Locale;
+import java.util.UUID;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.http.ResponseEntity;
+import org.springframework.util.ResourceUtils;
+
+/**
+ * Sample using xAPI client to post a statement with attachments.
+ *
+ * @author Thomas Turrell-Croft
+ * @author István Rátkai (Selindek)
+ */
+@SpringBootApplication
+public class PostStatementWithAttachmentApplication implements CommandLineRunner {
+
+ /**
+ * Default xAPI client. Properties are picked automatically from application.properties.
+ */
+ @Autowired
+ private XapiClient client;
+
+ public static void main(String[] args) {
+ SpringApplication.run(PostStatementWithAttachmentApplication.class, args).close();
+ }
+
+ @Override
+ public void run(String... args) throws Exception {
+
+ // Load jpg attachment from class-path
+ var data = Files.readAllBytes(ResourceUtils.getFile("classpath:example.jpg").toPath());
+
+ // Post a statement
+ ResponseEntity<
+ UUID> response =
+ client
+ .postStatement(r -> r.statement(
+ s -> s.actor(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")))
+
+ // Add simple text attachment
+ .addAttachment(a -> a.content("Simple attachment").length(17)
+ .contentType("text/plain")
+ .usageType(URI.create("https://example.com/attachments/greeting"))
+ .addDisplay(Locale.ENGLISH, "text attachment"))
+
+ // Add binary attachment
+ .addAttachment(a -> a.content(data).length(data.length)
+ .contentType("image/jpeg")
+ .usageType(URI.create("https://example.com/attachments/greeting"))
+ .addDisplay(Locale.ENGLISH, "JPEG attachment"))
+
+ )).block();
+
+ // If any attachment with actual data was added to any statement in a request, then it is sent
+ // as a multipart/mixed request automatically instead of the standard application/json format
+
+ // Print the statementId of the newly created statement to the console
+ System.out.println("StatementId " + response.getBody());
+ }
+
+}
diff --git a/samples/post-statement-with-attachment/src/main/resources/application.properties b/samples/post-statement-with-attachment/src/main/resources/application.properties
new file mode 100644
index 00000000..de20217a
--- /dev/null
+++ b/samples/post-statement-with-attachment/src/main/resources/application.properties
@@ -0,0 +1,3 @@
+xapi.client.username = admin
+xapi.client.password = password
+xapi.client.baseUrl = https://example.com/xapi/
diff --git a/samples/post-statement-with-attachment/src/main/resources/example.jpg b/samples/post-statement-with-attachment/src/main/resources/example.jpg
new file mode 100644
index 00000000..82123354
Binary files /dev/null and b/samples/post-statement-with-attachment/src/main/resources/example.jpg differ
diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartService.java b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartService.java
new file mode 100644
index 00000000..8e5bb97e
--- /dev/null
+++ b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartService.java
@@ -0,0 +1,191 @@
+/*
+ * Copyright 2016-2023 Berry Cloud Ltd. All rights reserved.
+ */
+
+package dev.learning.xapi.client;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.learning.xapi.model.Attachment;
+import dev.learning.xapi.model.Statement;
+import dev.learning.xapi.model.SubStatement;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.util.FastByteArrayOutputStream;
+import org.springframework.web.reactive.function.client.WebClient.RequestBodySpec;
+
+/**
+ * Helper methods for creating multipart message from statements.
+ *
+ * @author István Rátkai (Selindek)
+ */
+@Slf4j
+@RequiredArgsConstructor
+public final class MultipartService {
+
+ private static final String MULTIPART_BOUNDARY = "xapi-learning-dev-boundary";
+ private static final String MULTIPART_CONTENT_TYPE = "multipart/mixed; boundary="
+ + MULTIPART_BOUNDARY;
+ private static final String CRLF = "\r\n";
+ private static final String BOUNDARY_PREFIX = "--";
+ private static final String BODY_SEPARATOR = BOUNDARY_PREFIX + MULTIPART_BOUNDARY + CRLF;
+ private static final String BODY_FOOTER = BOUNDARY_PREFIX + MULTIPART_BOUNDARY + BOUNDARY_PREFIX;
+ private static final String CONTENT_TYPE = HttpHeaders.CONTENT_TYPE + ":";
+
+ private static final byte[] BA_APP_JSON_HEADER = (CONTENT_TYPE + MediaType.APPLICATION_JSON_VALUE
+ + CRLF + CRLF).getBytes(StandardCharsets.UTF_8);
+ private static final byte[] BA_CRLF = CRLF.getBytes(StandardCharsets.UTF_8);
+ private static final byte[] BA_BODY_SEPARATOR = BODY_SEPARATOR.getBytes(StandardCharsets.UTF_8);
+ private static final byte[] BA_BODY_FOOTER = BODY_FOOTER.getBytes(StandardCharsets.UTF_8);
+ private static final byte[] BA_CONTENT_TYPE = CONTENT_TYPE.getBytes(StandardCharsets.UTF_8);
+ private static final byte[] BA_ENCODING_HEADER = ("Content-Transfer-Encoding:binary" + CRLF)
+ .getBytes(StandardCharsets.UTF_8);
+ private static final byte[] BA_X_API_HASH = "X-Experience-API-Hash:"
+ .getBytes(StandardCharsets.UTF_8);
+
+ public static final MediaType MULTIPART_MEDIATYPE = MediaType.valueOf(MULTIPART_CONTENT_TYPE);
+
+ private final ObjectMapper objectMapper;
+
+ /**
+ *
+ * Add a Statement to the request.
+ *
+ * This method adds the statement and its attachments if there are any to the request body. Also
+ * sets the content-type to multipart/mixed if needed.
+ *
+ * @param requestSpec a {@link RequestBodySpec} object.
+ * @param statement a {@link Statement} to add.
+ */
+ public void addBody(RequestBodySpec requestSpec, Statement statement) {
+
+ addBody(requestSpec, statement, getRealAttachments(statement));
+
+ }
+
+ /**
+ *
+ * Adds a List of {@link Statement}s to the request.
+ *
+ * This method adds the statements and their attachments if there are any to the request body.
+ * Also sets the content-type to multipart/mixed if needed.
+ *
+ * @param requestSpec a {@link RequestBodySpec} object.
+ * @param statements list of {@link Statement}s to add.
+ */
+ public void addBody(RequestBodySpec requestSpec, List statements) {
+
+ addBody(requestSpec, statements, statements.stream().flatMap(this::getRealAttachments));
+
+ }
+
+ private void addBody(RequestBodySpec requestSpec, Object statements,
+ Stream attachments) {
+
+ final var attachmentsBody = writeAttachments(attachments);
+
+ if (attachmentsBody.length == 0) {
+ // add body directly, content-type is default application/json
+ requestSpec.bodyValue(statements);
+ } else {
+ // has at least one attachment with actual data -> set content-type
+ requestSpec.contentType(MULTIPART_MEDIATYPE);
+ // construct whole multipart body manually
+ requestSpec.bodyValue(createMultipartBody(statements, attachmentsBody));
+ }
+
+ }
+
+ /**
+ * Gets {@link Attachment}s of a {@link Statement} which has data property as a {@link Stream}.
+ *
+ * @param statement a {@link Statement} object
+ * @return {@link Attachment} of a {@link Statement} which has data property as a {@link Stream}.
+ */
+ private Stream getRealAttachments(Statement statement) {
+
+ // handle the rare scenario when a sub-statement has an attachment
+ Stream stream = statement.getObject() instanceof final SubStatement substatement
+ && substatement.getAttachments() != null ? substatement.getAttachments().stream()
+ : Stream.empty();
+
+ if (statement.getAttachments() != null) {
+ stream = Stream.concat(stream, statement.getAttachments().stream());
+ }
+
+ return stream.filter(a -> a.getContent() != null);
+ }
+
+ private byte[] createMultipartBody(Object statements, byte[] attachments) {
+
+ try (var stream = new FastByteArrayOutputStream()) {
+ // Multipart Boundary
+ stream.write(BA_BODY_SEPARATOR);
+
+ // Header of first part
+ stream.write(BA_APP_JSON_HEADER);
+
+ // Body of first part
+ stream.write(objectMapper.writeValueAsBytes(statements));
+ stream.write(BA_CRLF);
+
+ // Body of attachments
+ stream.write(attachments);
+
+ // Footer
+ stream.write(BA_BODY_FOOTER);
+
+ return stream.toByteArrayUnsafe();
+ } catch (final IOException e) {
+ log.error("Cannot create multipart body", e);
+ return new byte[] {};
+ }
+ }
+
+ /*
+ * Writes attachments to a byte array. If there are no attachments in the stream then returns an
+ * empty array.
+ */
+ private static byte[] writeAttachments(Stream attachments) {
+
+ try (var stream = new FastByteArrayOutputStream()) {
+
+ // Write each sha2-identical attachments only once
+ attachments.collect(Collectors.toMap(Attachment::getSha2, v -> v, (k, v) -> v)).values()
+ .forEach(a -> {
+ try {
+ // Multipart Boundary
+ stream.write(BA_BODY_SEPARATOR);
+
+ // Multipart headers
+ stream.write(BA_CONTENT_TYPE);
+ stream.write(a.getContentType().getBytes(StandardCharsets.UTF_8));
+ stream.write(BA_CRLF);
+
+ stream.write(BA_ENCODING_HEADER);
+
+ stream.write(BA_X_API_HASH);
+ stream.write(a.getSha2().getBytes(StandardCharsets.UTF_8));
+ stream.write(BA_CRLF);
+ stream.write(BA_CRLF);
+
+ // Multipart body
+ stream.write(a.getContent());
+ stream.write(BA_CRLF);
+ } catch (final IOException e) {
+ log.error("Cannot create multipart body", e);
+ }
+
+ });
+
+ return stream.toByteArrayUnsafe();
+ }
+ }
+
+}
diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java b/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java
index 0b1dc139..5b9aa3ff 100644
--- a/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java
+++ b/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java
@@ -4,6 +4,7 @@
package dev.learning.xapi.client;
+import com.fasterxml.jackson.databind.ObjectMapper;
import dev.learning.xapi.model.About;
import dev.learning.xapi.model.Activity;
import dev.learning.xapi.model.Person;
@@ -25,7 +26,6 @@
*
* @author Thomas Turrell-Croft
* @author István Rátkai (Selindek)
- *
* @see xAPI
* communication resources
@@ -33,20 +33,24 @@
public class XapiClient {
private final WebClient webClient;
+ private final MultipartService multipartService;
- private static final ParameterizedTypeReference> LIST_UUID_TYPE =
- new ParameterizedTypeReference<>() {};
+ private static final ParameterizedTypeReference<
+ List> LIST_UUID_TYPE = new ParameterizedTypeReference<>() {
+ };
- private static final ParameterizedTypeReference> LIST_STRING_TYPE =
- new ParameterizedTypeReference<>() {};
+ private static final ParameterizedTypeReference<
+ List> LIST_STRING_TYPE = new ParameterizedTypeReference<>() {
+ };
/**
* Default constructor for XapiClient.
*
* @param builder a {@link WebClient.Builder} object. The caller must set the baseUrl and the
- * authorization header.
+ * authorization header.
*/
- public XapiClient(WebClient.Builder builder) {
+ public XapiClient(WebClient.Builder builder, ObjectMapper objectMapper) {
+ this.multipartService = new MultipartService(objectMapper);
this.webClient = builder
.defaultHeader("X-Experience-API-Version", "1.0.3")
@@ -111,15 +115,15 @@ public Mono> postStatement(PostStatementRequest request) {
final Map queryParams = new HashMap<>();
- return this.webClient
+ final var requestSpec = this.webClient
.method(request.getMethod())
- .uri(u -> request.url(u, queryParams).build(queryParams))
+ .uri(u -> request.url(u, queryParams).build(queryParams));
- .bodyValue(request.getStatement())
+ multipartService.addBody(requestSpec, request.getStatement());
- .retrieve()
+ return requestSpec.retrieve()
.toEntity(LIST_UUID_TYPE)
@@ -158,15 +162,15 @@ public Mono>> postStatements(PostStatementsRequest req
final Map queryParams = new HashMap<>();
- return this.webClient
+ final var requestSpec = this.webClient
.method(request.getMethod())
- .uri(u -> request.url(u, queryParams).build(queryParams))
+ .uri(u -> request.url(u, queryParams).build(queryParams));
- .bodyValue(request.getStatements())
+ multipartService.addBody(requestSpec, request.getStatements());
- .retrieve()
+ return requestSpec.retrieve()
.toEntity(LIST_UUID_TYPE);
@@ -257,7 +261,6 @@ public Mono> getStatements() {
*
*
* @param request The parameters of the get statements request
- *
* @return the ResponseEntity
*/
public Mono> getStatements(GetStatementsRequest request) {
@@ -284,7 +287,6 @@ public Mono> getStatements(GetStatementsRequest
*
*
* @param request The Consumer Builder for the get statements request
- *
* @return the ResponseEntity
*/
public Mono> getStatements(
@@ -306,7 +308,6 @@ public Mono> getStatements(
*
*
* @param request The parameters of the get more statements request
- *
* @return the ResponseEntity
*/
public Mono> getMoreStatements(GetMoreStatementsRequest request) {
@@ -333,7 +334,6 @@ public Mono> getMoreStatements(GetMoreStatements
*
*
* @param request The Consumer Builder for the get more statements request
- *
* @return the ResponseEntity
*/
public Mono> getMoreStatements(
@@ -357,7 +357,6 @@ public Mono> getMoreStatements(
*
*
* @param request The parameters of the get state request
- *
* @return the ResponseEntity
*/
public Mono> getState(GetStateRequest request, Class bodyType) {
@@ -384,7 +383,6 @@ public Mono> getState(GetStateRequest request, Class bo
*
*
* @param request The Consumer Builder for the get state request
- *
* @return the ResponseEntity
*/
public Mono> getState(Consumer> request,
@@ -406,7 +404,6 @@ public Mono> getState(Consumer
*
* @param request The parameters of the post state request
- *
* @return the ResponseEntity
*/
public Mono> postState(PostStateRequest request) {
@@ -437,7 +434,6 @@ public Mono> postState(PostStateRequest request) {
*
*
* @param request The Consumer Builder for the post state request
- *
* @return the ResponseEntity
*/
public Mono> postState(Consumer> request) {
@@ -458,7 +454,6 @@ public Mono> postState(Consumer
*
* @param request The parameters of the put state request
- *
* @return the ResponseEntity
*/
public Mono> putState(PutStateRequest request) {
@@ -489,7 +484,6 @@ public Mono> putState(PutStateRequest request) {
*
*
* @param request The Consumer Builder for the put state request
- *
* @return the ResponseEntity
*/
public Mono> putState(Consumer> request) {
@@ -510,7 +504,6 @@ public Mono> putState(Consumer
*
* @param request The parameters of the delete state request
- *
* @return the ResponseEntity
*/
public Mono> deleteState(DeleteStateRequest request) {
@@ -537,7 +530,6 @@ public Mono> deleteState(DeleteStateRequest request) {
*
*
* @param request The Consumer Builder for the delete state request
- *
* @return the ResponseEntity
*/
public Mono> deleteState(
@@ -556,7 +548,6 @@ public Mono> deleteState(
* parameters.
*
* @param request The parameters of the get states request
- *
* @return the ResponseEntity
*/
public Mono>> getStates(GetStatesRequest request) {
@@ -583,7 +574,6 @@ public Mono>> getStates(GetStatesRequest request) {
*
*
* @param request The Consumer Builder for the get states request
- *
* @return the ResponseEntity
*/
public Mono>> getStates(
@@ -604,7 +594,6 @@ public Mono>> getStates(
*
*
* @param request The parameters of the delete states request
- *
* @return the ResponseEntity
*/
public Mono> deleteStates(DeleteStatesRequest request) {
@@ -630,7 +619,6 @@ public Mono> deleteStates(DeleteStatesRequest request) {
*
*
* @param request The Consumer Builder for the delete states request
- *
* @return the ResponseEntity
*/
public Mono> deleteStates(
@@ -652,7 +640,6 @@ public Mono> deleteStates(
* value, and it is legal to include multiple identifying properties.
*
* @param request The parameters of the get agents request
- *
* @return the ResponseEntity
*/
public Mono> getAgents(GetAgentsRequest request) {
@@ -677,7 +664,6 @@ public Mono> getAgents(GetAgentsRequest request) {
* value, and it is legal to include multiple identifying properties.
*
* @param request The Consumer Builder for the get agents request
- *
* @return the ResponseEntity
*/
public Mono> getAgents(Consumer request) {
@@ -696,7 +682,6 @@ public Mono> getAgents(Consumer
* Loads the complete Activity Object specified.
*
* @param request The parameters of the get activity request
- *
* @return the ResponseEntity
*/
public Mono> getActivity(GetActivityRequest request) {
@@ -719,7 +704,6 @@ public Mono> getActivity(GetActivityRequest request) {
* Loads the complete Activity Object specified.
*
* @param request The Consumer Builder for the get activity request
- *
* @return the ResponseEntity
*/
public Mono> getActivity(Consumer request) {
@@ -741,7 +725,6 @@ public Mono> getActivity(Consumer
*
* @param request The parameters of the get agent profile request
- *
* @return the ResponseEntity
*/
public Mono> getAgentProfile(GetAgentProfileRequest request,
@@ -768,7 +751,6 @@ public Mono> getAgentProfile(GetAgentProfileRequest reques
*
*
* @param request The Consumer Builder for the get agent profile request
- *
* @return the ResponseEntity
*/
public Mono> getAgentProfile(
@@ -789,7 +771,6 @@ public Mono> getAgentProfile(
*
*
* @param request The parameters of the delete agent profile request
- *
* @return the ResponseEntity
*/
public Mono> deleteAgentProfile(DeleteAgentProfileRequest request) {
@@ -815,7 +796,6 @@ public Mono> deleteAgentProfile(DeleteAgentProfileRequest r
*
*
* @param request The Consumer Builder for the delete agent profile request
- *
* @return the ResponseEntity
*/
public Mono> deleteAgentProfile(
@@ -836,7 +816,6 @@ public Mono> deleteAgentProfile(
*
*
* @param request The parameters of the put agent profile request
- *
* @return the ResponseEntity
*/
public Mono> putAgentProfile(PutAgentProfileRequest request) {
@@ -866,7 +845,6 @@ public Mono> putAgentProfile(PutAgentProfileRequest request
*
*
* @param request The Consumer Builder for the put agent profile request
- *
* @return the ResponseEntity
*/
public Mono> putAgentProfile(
@@ -887,7 +865,6 @@ public Mono> putAgentProfile(
*
*
* @param request The parameters of the post agent profile request
- *
* @return the ResponseEntity
*/
public Mono> postAgentProfile(PostAgentProfileRequest request) {
@@ -917,7 +894,6 @@ public Mono> postAgentProfile(PostAgentProfileRequest reque
*
*
* @param request The Consumer Builder for the post agent profile request
- *
* @return the ResponseEntity
*/
public Mono> postAgentProfile(
@@ -937,7 +913,6 @@ public Mono> postAgentProfile(
* (exclusive).
*
* @param request The parameters of the get agent profiles request
- *
* @return the ResponseEntity
*/
public Mono>> getAgentProfiles(GetAgentProfilesRequest request) {
@@ -962,7 +937,6 @@ public Mono>> getAgentProfiles(GetAgentProfilesReque
* (exclusive).
*
* @param request The Consumer Builder for the get agent profiles request
- *
* @return the ResponseEntity
*/
public Mono>> getAgentProfiles(
@@ -985,7 +959,6 @@ public Mono>> getAgentProfiles(
*
*
* @param request The parameters of the get activity profile request
- *
* @return the ResponseEntity
*/
public Mono> getActivityProfile(GetActivityProfileRequest request,
@@ -1012,7 +985,6 @@ public Mono> getActivityProfile(GetActivityProfileRequest
*
*
* @param request The Consumer Builder for the get activity profile request
- *
* @return the ResponseEntity
*/
public Mono> getActivityProfile(
@@ -1033,7 +1005,6 @@ public Mono> getActivityProfile(
*
*
* @param request The parameters of the post activity profile request
- *
* @return the ResponseEntity
*/
public Mono> postActivityProfile(PostActivityProfileRequest request) {
@@ -1063,7 +1034,6 @@ public Mono> postActivityProfile(PostActivityProfileRequest
*
*
* @param request The Consumer Builder for the post activity profile request
- *
* @return the ResponseEntity
*/
public Mono> postActivityProfile(
@@ -1084,7 +1054,6 @@ public Mono> postActivityProfile(
*
*
* @param request The parameters of the put activity profile request
- *
* @return the ResponseEntity
*/
public Mono> putActivityProfile(PutActivityProfileRequest request) {
@@ -1114,7 +1083,6 @@ public Mono> putActivityProfile(PutActivityProfileRequest r
*
*
* @param request The Consumer Builder for the put activity profile request
- *
* @return the ResponseEntity
*/
public Mono> putActivityProfile(
@@ -1135,7 +1103,6 @@ public Mono> putActivityProfile(
*
*
* @param request The parameters of the delete activity profile request
- *
* @return the ResponseEntity
*/
public Mono> deleteActivityProfile(DeleteActivityProfileRequest request) {
@@ -1161,14 +1128,13 @@ public Mono> deleteActivityProfile(DeleteActivityProfileReq
*
*
* @param request The Consumer Builder for the delete activity profile request
- *
* @return the ResponseEntity
*/
public Mono> deleteActivityProfile(
Consumer> request) {
- final DeleteActivityProfileRequest.Builder, ?> builder =
- DeleteActivityProfileRequest.builder();
+ final DeleteActivityProfileRequest.Builder,
+ ?> builder = DeleteActivityProfileRequest.builder();
request.accept(builder);
@@ -1185,7 +1151,6 @@ public Mono> deleteActivityProfile(
*
*
* @param request The parameters of the get activity profiles request
- *
* @return the ResponseEntity
*/
public Mono>> getActivityProfiles(
@@ -1214,7 +1179,6 @@ public Mono>> getActivityProfiles(
*
*
* @param request The Consumer Builder for the get activity profiles request
- *
* @return the ResponseEntity
*/
public Mono>> getActivityProfiles(
diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/configuration/XapiClientAutoConfiguration.java b/xapi-client/src/main/java/dev/learning/xapi/client/configuration/XapiClientAutoConfiguration.java
index ab2438ac..9e83e8fd 100644
--- a/xapi-client/src/main/java/dev/learning/xapi/client/configuration/XapiClientAutoConfiguration.java
+++ b/xapi-client/src/main/java/dev/learning/xapi/client/configuration/XapiClientAutoConfiguration.java
@@ -4,6 +4,7 @@
package dev.learning.xapi.client.configuration;
+import com.fasterxml.jackson.databind.ObjectMapper;
import dev.learning.xapi.client.XapiClient;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -28,7 +29,7 @@ public class XapiClientAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public XapiClient xapiClient(XapiClientProperties properties, WebClient.Builder builder,
- List configurers) {
+ List configurers, ObjectMapper objectMapper) {
if (properties.getAuthorization() != null) {
builder.defaultHeader(HttpHeaders.AUTHORIZATION, properties.getAuthorization());
@@ -44,7 +45,7 @@ public XapiClient xapiClient(XapiClientProperties properties, WebClient.Builder
configurers.forEach(c -> c.accept(builder));
- return new XapiClient(builder);
+ return new XapiClient(builder, objectMapper);
}
diff --git a/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientMultipartTests.java b/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientMultipartTests.java
new file mode 100644
index 00000000..c89b9666
--- /dev/null
+++ b/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientMultipartTests.java
@@ -0,0 +1,305 @@
+/*
+ * Copyright 2016rue-2023 Berry Cloud Ltd. All rights reserved.
+ */
+package dev.learning.xapi.client;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.StringStartsWith.startsWith;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.learning.xapi.model.Activity;
+import dev.learning.xapi.model.Agent;
+import dev.learning.xapi.model.Statement;
+import dev.learning.xapi.model.SubStatement;
+import dev.learning.xapi.model.Verb;
+import java.net.URI;
+import java.time.Instant;
+import java.util.Locale;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.web.reactive.function.client.WebClient;
+
+/**
+ * XapiClient Tests.
+ *
+ * @author Thomas Turrell-Croft
+ */
+@DisplayName("XapiClient Tests")
+@SpringBootTest
+class XapiClientMultipartTests {
+
+ @Autowired
+ private WebClient.Builder webClientBuilder;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ private MockWebServer mockWebServer;
+ private XapiClient client;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ mockWebServer = new MockWebServer();
+ mockWebServer.start();
+
+ webClientBuilder.baseUrl(mockWebServer.url("").toString());
+
+ client = new XapiClient(webClientBuilder, objectMapper);
+
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ mockWebServer.shutdown();
+ }
+
+ @Test
+ void whenPostingStatementWithAttachmentThenContentTypeHeaderIsMultipartMixed()
+ throws InterruptedException {
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK")
+ .setBody("[\"19a74a3f-7354-4254-aa4a-1c39ab4f2ca7\"]")
+ .setHeader("Content-Type", "application/json"));
+
+ // When Posting Statement With Attachment
+ client.postStatement(
+ r -> r.statement(s -> s.actor(a -> a.name("A N Other").mbox("mailto:another@example.com"))
+
+ .addAttachment(a -> a.content("Simple attachment").length(17).contentType("text/plain")
+ .usageType(URI.create("http://adlnet.gov/expapi/attachments/text"))
+ .addDisplay(Locale.ENGLISH, "text attachment"))
+
+ .verb(Verb.ATTEMPTED)
+
+ .activityObject(o -> o.id("https://example.com/activity/simplestatement")
+ .definition(d -> d.addName(Locale.ENGLISH, "Simple Statement")))))
+ .block();
+
+ final var recordedRequest = mockWebServer.takeRequest();
+
+ // Then Content Type Header Is Multipart Mixed
+ assertThat(recordedRequest.getHeader("content-type"), startsWith("multipart/mixed"));
+ }
+
+ @Test
+ void whenPostingStatementWithTextAttachmentThenBodyIsExpected() throws InterruptedException {
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK")
+ .setBody("[\"19a74a3f-7354-4254-aa4a-1c39ab4f2ca7\"]")
+ .setHeader("Content-Type", "application/json"));
+
+ // When Posting Statement With Text Attachment
+ client.postStatement(
+ r -> r.statement(s -> s.actor(a -> a.name("A N Other").mbox("mailto:another@example.com"))
+
+ .addAttachment(a -> a.content("Simple attachment").length(17).contentType("text/plain")
+ .usageType(URI.create("http://adlnet.gov/expapi/attachments/text"))
+ .addDisplay(Locale.ENGLISH, "text attachment"))
+
+ .verb(Verb.ATTEMPTED)
+
+ .activityObject(o -> o.id("https://example.com/activity/simplestatement")
+ .definition(d -> d.addName(Locale.ENGLISH, "Simple Statement")))))
+ .block();
+
+ final var recordedRequest = mockWebServer.takeRequest();
+
+ // Then Body Is Expected
+ assertThat(recordedRequest.getBody().readUtf8(), is(
+ "--xapi-learning-dev-boundary\r\nContent-Type:application/json\r\n\r\n{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/text\",\"display\":{\"en\":\"text attachment\"},\"contentType\":\"text/plain\",\"length\":17,\"sha2\":\"b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\"}]}\r\n--xapi-learning-dev-boundary\r\nContent-Type:text/plain\r\nContent-Transfer-Encoding:binary\r\nX-Experience-API-Hash:b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\r\n\r\nSimple attachment\r\n--xapi-learning-dev-boundary--"));
+ }
+
+ @Test
+ void whenPostingStatementWithBinaryAttachmentThenBodyIsExpected() throws InterruptedException {
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK")
+ .setBody("[\"19a74a3f-7354-4254-aa4a-1c39ab4f2ca7\"]")
+ .setHeader("Content-Type", "application/json"));
+
+ // When Posting Statement With Binary Attachment
+ client.postStatement(
+ r -> r.statement(s -> s.actor(a -> a.name("A N Other").mbox("mailto:another@example.com"))
+
+ .addAttachment(a -> a.content(new byte[] {64, 65, 66, 67, 68, (byte) 255}).length(6)
+ .contentType("application/octet-stream")
+ .usageType(URI.create("http://adlnet.gov/expapi/attachments/code"))
+ .addDisplay(Locale.ENGLISH, "binary attachment"))
+
+ .verb(Verb.ATTEMPTED)
+
+ .activityObject(o -> o.id("https://example.com/activity/simplestatement")
+ .definition(d -> d.addName(Locale.ENGLISH, "Simple Statement")))))
+ .block();
+
+ final var recordedRequest = mockWebServer.takeRequest();
+
+ // Then Body Is Expected
+ assertThat(recordedRequest.getBody().readUtf8(), is(
+ "--xapi-learning-dev-boundary\r\nContent-Type:application/json\r\n\r\n{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/code\",\"display\":{\"en\":\"binary attachment\"},\"contentType\":\"application/octet-stream\",\"length\":6,\"sha2\":\"0f4b9b79ad9e0572dbc7ce7d4dd38b96dc66d28ca87d7fd738ec8f9a30935bf6\"}]}\r\n--xapi-learning-dev-boundary\r\nContent-Type:application/octet-stream\r\nContent-Transfer-Encoding:binary\r\nX-Experience-API-Hash:0f4b9b79ad9e0572dbc7ce7d4dd38b96dc66d28ca87d7fd738ec8f9a30935bf6\r\n\r\n@ABCD�\r\n--xapi-learning-dev-boundary--"));
+ }
+
+ @Test
+ void whenPostingStatementWithoutAttachmentDataThenBodyIsExpected() throws InterruptedException {
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK")
+ .setBody("[\"19a74a3f-7354-4254-aa4a-1c39ab4f2ca7\"]")
+ .setHeader("Content-Type", "application/json"));
+
+ // When Posting Statement Without Attachment Data
+ client.postStatement(
+ r -> r.statement(s -> s.actor(a -> a.name("A N Other").mbox("mailto:another@example.com"))
+
+ .addAttachment(a -> a.length(6).contentType("application/octet-stream")
+ .usageType(URI.create("http://adlnet.gov/expapi/attachments/code"))
+ .fileUrl(URI.create("example.com/attachment"))
+ .addDisplay(Locale.ENGLISH, "binary attachment"))
+
+ .verb(Verb.ATTEMPTED)
+
+ .activityObject(o -> o.id("https://example.com/activity/simplestatement")
+ .definition(d -> d.addName(Locale.ENGLISH, "Simple Statement")))))
+ .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/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/code\",\"display\":{\"en\":\"binary attachment\"},\"contentType\":\"application/octet-stream\",\"length\":6,\"fileUrl\":\"example.com/attachment\"}]}"));
+ }
+
+ @Test
+ void whenPostingSubStatementWithTextAttachmentThenBodyIsExpected() throws InterruptedException {
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK")
+ .setBody("[\"19a74a3f-7354-4254-aa4a-1c39ab4f2ca7\"]")
+ .setHeader("Content-Type", "application/json"));
+
+ // When Posting SubStatement With Text Attachment
+ client.postStatement(r -> r.statement(s -> s
+ .actor(a -> a.name("A N Other").mbox("mailto:another@example.com"))
+
+ .verb(Verb.ABANDONED)
+
+ .object(SubStatement.builder()
+
+ .actor(Agent.builder().name("A N Other").mbox("mailto:another@example.com").build())
+
+ .verb(Verb.ATTENDED)
+
+ .object(Activity.builder().id("https://example.com/activity/simplestatement")
+ .definition(d -> d.addName(Locale.ENGLISH, "Simple Statement")).build())
+
+ .addAttachment(a -> a.content("Simple attachment").length(17).contentType("text/plain")
+ .usageType(URI.create("http://adlnet.gov/expapi/attachments/text"))
+ .addDisplay(Locale.ENGLISH, "text attachment"))
+
+ .build())
+
+ )).block();
+
+ final var recordedRequest = mockWebServer.takeRequest();
+
+ // Then Body Is Expected
+ assertThat(recordedRequest.getBody().readUtf8(), is(
+ "--xapi-learning-dev-boundary\r\nContent-Type:application/json\r\n\r\n{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"https://w3id.org/xapi/adl/verbs/abandoned\",\"display\":{\"und\":\"abandoned\"}},\"object\":{\"objectType\":\"SubStatement\",\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attended\",\"display\":{\"und\":\"attended\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/text\",\"display\":{\"en\":\"text attachment\"},\"contentType\":\"text/plain\",\"length\":17,\"sha2\":\"b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\"}]}}\r\n--xapi-learning-dev-boundary\r\nContent-Type:text/plain\r\nContent-Transfer-Encoding:binary\r\nX-Experience-API-Hash:b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\r\n\r\nSimple attachment\r\n--xapi-learning-dev-boundary--"));
+ }
+
+ @Test
+ void whenPostingStatementsWithAttachmentsThenBodyIsExpected() throws InterruptedException {
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK")
+ .setBody("[\"19a74a3f-7354-4254-aa4a-1c39ab4f2ca7\"]")
+ .setHeader("Content-Type", "application/json"));
+
+ // When Posting Statements With Attachments
+ final var statement1 = Statement.builder()
+
+ .actor(a -> a.name("A N Other").mbox("mailto:another@example.com"))
+
+ .addAttachment(a -> a.content(new byte[] {64, 65, 66, 67, 68, 69}).length(6)
+ .contentType("application/octet-stream")
+ .usageType(URI.create("http://adlnet.gov/expapi/attachments/code"))
+ .addDisplay(Locale.ENGLISH, "binary attachment"))
+
+ .verb(Verb.ATTEMPTED)
+
+ .activityObject(o -> o.id("https://example.com/activity/simplestatement")
+ .definition(d -> d.addName(Locale.ENGLISH, "Simple Statement")))
+
+ .build();
+
+ final var statement2 = Statement.builder()
+
+ .actor(a -> a.name("A N Other").mbox("mailto:another@example.com"))
+
+ .addAttachment(a -> a.content(new byte[] {64, 65, 66, 67, 68, 69}).length(6)
+ .contentType("application/octet-stream")
+ .usageType(URI.create("http://adlnet.gov/expapi/attachments/code"))
+ .addDisplay(Locale.ENGLISH, "binary attachment"))
+
+ .addAttachment(a -> a.content("Simple attachment").length(17).contentType("text/plain")
+ .usageType(URI.create("http://adlnet.gov/expapi/attachments/text"))
+ .addDisplay(Locale.ENGLISH, "text attachment"))
+
+ .verb(Verb.ATTEMPTED)
+
+ .activityObject(o -> o.id("https://example.com/activity/simplestatement")
+ .definition(d -> d.addName(Locale.ENGLISH, "Simple Statement")))
+
+ .build();
+
+ // When posting Statements
+ client.postStatements(r -> r.statements(statement1, statement2)).block();
+
+ final var recordedRequest = mockWebServer.takeRequest();
+
+ // Then Body Is Expected
+ assertThat(recordedRequest.getBody().readUtf8(), is(
+ "--xapi-learning-dev-boundary\r\nContent-Type:application/json\r\n\r\n[{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/code\",\"display\":{\"en\":\"binary attachment\"},\"contentType\":\"application/octet-stream\",\"length\":6,\"sha2\":\"0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef\"}]},{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/code\",\"display\":{\"en\":\"binary attachment\"},\"contentType\":\"application/octet-stream\",\"length\":6,\"sha2\":\"0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef\"},{\"usageType\":\"http://adlnet.gov/expapi/attachments/text\",\"display\":{\"en\":\"text attachment\"},\"contentType\":\"text/plain\",\"length\":17,\"sha2\":\"b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\"}]}]\r\n--xapi-learning-dev-boundary\r\nContent-Type:text/plain\r\nContent-Transfer-Encoding:binary\r\nX-Experience-API-Hash:b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\r\n\r\nSimple attachment\r\n--xapi-learning-dev-boundary\r\nContent-Type:application/octet-stream\r\nContent-Transfer-Encoding:binary\r\nX-Experience-API-Hash:0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef\r\n\r\n@ABCDE\r\n--xapi-learning-dev-boundary--"));
+ }
+
+
+
+ @Test
+ void whenPostingStatementsWithTimestampAndAttachmentThenNoExceptionIsThrown()
+ throws InterruptedException {
+
+ mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK")
+ .setBody("[\"19a74a3f-7354-4254-aa4a-1c39ab4f2ca7\"]")
+ .setHeader("Content-Type", "application/json"));
+
+ final var statement = Statement.builder()
+
+ .actor(a -> a.name("A N Other").mbox("mailto:another@example.com"))
+
+ .verb(Verb.ATTEMPTED)
+
+ .activityObject(o -> o.id("https://example.com/activity/simplestatement"))
+
+ .addAttachment(a -> a.content(new byte[] {64, 65, 66, 67, 68, 69}).length(6)
+ .contentType("application/octet-stream")
+ .usageType(URI.create("http://example.com/attachment"))
+ .addDisplay(Locale.ENGLISH, "binary attachment"))
+
+ .timestamp(Instant.now())
+
+ .build();
+
+ // When Posting Statements With Timestamp And Attachment
+
+ // Then No Exception Is Thrown
+ assertDoesNotThrow(() -> client.postStatements(r -> r.statements(statement)).block());
+
+ }
+
+
+
+}
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 2300b3ee..bce73ac9 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
@@ -7,6 +7,7 @@
import static org.hamcrest.core.Is.is;
import static org.hamcrest.core.IsInstanceOf.instanceOf;
+import com.fasterxml.jackson.databind.ObjectMapper;
import dev.learning.xapi.model.About;
import dev.learning.xapi.model.Activity;
import dev.learning.xapi.model.Person;
@@ -44,6 +45,9 @@ class XapiClientTests {
@Autowired
private WebClient.Builder webClientBuilder;
+ @Autowired
+ private ObjectMapper objectMapper;
+
private MockWebServer mockWebServer;
private XapiClient client;
@@ -54,7 +58,7 @@ void setUp() throws Exception {
webClientBuilder.baseUrl(mockWebServer.url("").toString());
- client = new XapiClient(webClientBuilder);
+ client = new XapiClient(webClientBuilder, objectMapper);
}
@@ -104,8 +108,8 @@ void whenGettingStatementThenBodyIsInstanceOfStatement() throws InterruptedExcep
.addHeader("Content-Type", "application/json; charset=utf-8"));
// When Getting Statement
- final var response =
- client.getStatement(r -> r.id("4df42866-40e7-45b6-bf7c-8d5fccbdccd6")).block();
+ final var response = client.getStatement(r -> r.id("4df42866-40e7-45b6-bf7c-8d5fccbdccd6"))
+ .block();
// Then Body Is Instance Of Statement
assertThat(response.getBody(), instanceOf(Statement.class));
@@ -176,7 +180,6 @@ void whenPostingStatementsThenMethodIsPost() throws InterruptedException {
assertThat(recordedRequest.getMethod(), is("POST"));
}
-
@Test
void whenPostingStatementsThenBodyIsExpected() throws InterruptedException {
@@ -205,7 +208,6 @@ void whenPostingStatementsThenBodyIsExpected() throws InterruptedException {
"[{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}}},{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/passed\",\"display\":{\"und\":\"passed\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}}}]"));
}
-
@Test
void whenPostingStatementsArrayThenBodyIsExpected() throws InterruptedException {
@@ -1798,8 +1800,8 @@ void whenGettingAgentsThenBodyIsInstanceOfPerson() throws InterruptedException {
.addHeader("Content-Type", "application/json; charset=utf-8"));
// When Getting Agents
- final var response =
- client.getAgents(r -> r.agent(a -> a.mbox("mailto:another@example.com"))).block();
+ final var response = client.getAgents(r -> r.agent(a -> a.mbox("mailto:another@example.com")))
+ .block();
// Then Body Is Instance Of Activity
assertThat(response.getBody(), instanceOf(Person.class));
diff --git a/xapi-client/src/test/java/dev/learning/xapi/client/configuration/XapiClientAutoConfigurationAuthorizationTest.java b/xapi-client/src/test/java/dev/learning/xapi/client/configuration/XapiClientAutoConfigurationAuthorizationTest.java
index b2138ce9..54983f7d 100644
--- a/xapi-client/src/test/java/dev/learning/xapi/client/configuration/XapiClientAutoConfigurationAuthorizationTest.java
+++ b/xapi-client/src/test/java/dev/learning/xapi/client/configuration/XapiClientAutoConfigurationAuthorizationTest.java
@@ -16,6 +16,7 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
@@ -29,7 +30,7 @@
@DisplayName("XapiClientAutoConfigurationAuthorization Test")
@SpringBootTest(
classes = { XapiClientAutoConfiguration.class, WebClientAutoConfiguration.class,
- XapiTestClientConfiguration2.class },
+ XapiTestClientConfiguration2.class, JacksonAutoConfiguration.class },
properties = "xapi.client.authorization = bearer 1234")
class XapiClientAutoConfigurationAuthorizationTest {
diff --git a/xapi-client/src/test/java/dev/learning/xapi/client/configuration/XapiClientAutoConfigurationBaseUrlTest.java b/xapi-client/src/test/java/dev/learning/xapi/client/configuration/XapiClientAutoConfigurationBaseUrlTest.java
index f8d936c5..503e56ff 100644
--- a/xapi-client/src/test/java/dev/learning/xapi/client/configuration/XapiClientAutoConfigurationBaseUrlTest.java
+++ b/xapi-client/src/test/java/dev/learning/xapi/client/configuration/XapiClientAutoConfigurationBaseUrlTest.java
@@ -4,6 +4,7 @@
package dev.learning.xapi.client.configuration;
import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.AnyOf.anyOf;
import static org.hamcrest.core.Is.is;
import dev.learning.xapi.client.XapiClient;
@@ -14,6 +15,7 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
@@ -23,8 +25,10 @@
* @author István Rátkai (Selindek)
*/
@DisplayName("XapiClientAutoConfigurationBaseUrl Test")
-@SpringBootTest(classes = {XapiClientAutoConfiguration.class, WebClientAutoConfiguration.class},
- properties = {"xapi.client.baseUrl = http://127.0.0.1:55123/"})
+@SpringBootTest(
+ classes = { XapiClientAutoConfiguration.class, WebClientAutoConfiguration.class,
+ JacksonAutoConfiguration.class },
+ properties = { "xapi.client.baseUrl = http://127.0.0.1:55123/" })
class XapiClientAutoConfigurationBaseUrlTest {
@Autowired
@@ -52,8 +56,9 @@ void whenConfiguringXapiClientThenBaseUrlIsSet() throws InterruptedException {
final var recordedRequest = mockWebServer.takeRequest();
// Then BaseUrl Is Set (Request was sent to the proper url)
- assertThat(recordedRequest.getRequestUrl().toString(),
- is("http://localhost:55123/statements?statementId=4df42866-40e7-45b6-bf7c-8d5fccbdccd6"));
+ assertThat(recordedRequest.getRequestUrl().toString(), anyOf(
+ is("http://127.0.0.1:55123/statements?statementId=4df42866-40e7-45b6-bf7c-8d5fccbdccd6"),
+ is("http://localhost:55123/statements?statementId=4df42866-40e7-45b6-bf7c-8d5fccbdccd6")));
}
}
diff --git a/xapi-client/src/test/java/dev/learning/xapi/client/configuration/XapiClientAutoConfigurationUsernamePasswordTest.java b/xapi-client/src/test/java/dev/learning/xapi/client/configuration/XapiClientAutoConfigurationUsernamePasswordTest.java
index afcdd7e5..e1fa55ff 100644
--- a/xapi-client/src/test/java/dev/learning/xapi/client/configuration/XapiClientAutoConfigurationUsernamePasswordTest.java
+++ b/xapi-client/src/test/java/dev/learning/xapi/client/configuration/XapiClientAutoConfigurationUsernamePasswordTest.java
@@ -16,6 +16,7 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
@@ -29,7 +30,7 @@
@DisplayName("XapiClientAutoConfigurationUsernamePassword Test")
@SpringBootTest(
classes = { XapiClientAutoConfiguration.class, WebClientAutoConfiguration.class,
- XapiTestClientConfiguration.class },
+ XapiTestClientConfiguration.class, JacksonAutoConfiguration.class },
properties = { "xapi.client.username = username", "xapi.client.password = password" })
class XapiClientAutoConfigurationUsernamePasswordTest {
diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/Attachment.java b/xapi-model/src/main/java/dev/learning/xapi/model/Attachment.java
index 48dfb75b..45b7dfb9 100644
--- a/xapi-model/src/main/java/dev/learning/xapi/model/Attachment.java
+++ b/xapi-model/src/main/java/dev/learning/xapi/model/Attachment.java
@@ -4,10 +4,14 @@
package dev.learning.xapi.model;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import jakarta.validation.constraints.NotNull;
import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.util.Locale;
import lombok.Builder;
import lombok.Value;
@@ -69,6 +73,12 @@ public class Attachment {
*/
private URI fileUrl;
+ /**
+ * The data of the attachment as byte array.
+ */
+ @JsonIgnore
+ private byte[] content;
+
// **Warning** do not add fields that are not required by the xAPI specification.
/**
@@ -117,6 +127,93 @@ public Builder addDescription(Locale key, String value) {
return this;
}
+
+ /**
+ *
+ * Sets SHA-2 hash of the Attachment.
+ *
+ *
+ * The sha2 is set ONLY if the content property was not set yet. (otherwise the sha2 is
+ * calculated automatically)
+ *
+ *
+ * @param sha2 The SHA-2 hash of the Attachment data.
+ *
+ * @return This builder
+ */
+ public Builder sha2(String sha2) {
+ if (this.content == null) {
+ this.sha2 = sha2;
+ }
+
+ return this;
+
+ }
+
+ /**
+ *
+ * Sets data of the Attachment.
+ *
+ *
+ * This method also automatically calculates the SHA-2 hash for the data.
+ *
+ *
+ * @param content The data of the Attachment as a byte array.
+ *
+ * @return This builder
+ */
+ public Builder content(byte[] content) {
+ this.content = content;
+ if (content != null) {
+ this.sha2 = sha256Hex(content);
+ }
+
+ return this;
+
+ }
+
+ /**
+ *
+ * Sets data of the Attachment as a String.
+ *
+ *
+ * This is a convenient method for creating text attachments.
+ *
+ *
+ * @param content The data of the Attachment as a String.
+ *
+ * @return This builder
+ *
+ * @see Builder#content(byte[])
+ */
+ public Builder content(String content) {
+
+ if (content != null) {
+ return content(content.getBytes(StandardCharsets.UTF_8));
+ }
+
+ return content((byte[]) null);
+
+ }
+
+ private static String sha256Hex(byte[] data) {
+ try {
+ final var digest = MessageDigest.getInstance("SHA-256");
+ final var hash = digest.digest(data);
+ final var hexString = new StringBuilder(2 * hash.length);
+ for (final byte element : hash) {
+ final var hex = Integer.toHexString(0xff & element);
+ if (hex.length() == 1) {
+ hexString.append('0');
+ }
+ hexString.append(hex);
+ }
+ return hexString.toString();
+ } catch (final NoSuchAlgorithmException e) {
+ throw new IllegalArgumentException(e);
+ }
+
+ }
}
}
diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/Statement.java b/xapi-model/src/main/java/dev/learning/xapi/model/Statement.java
index 65775162..3c94b89c 100644
--- a/xapi-model/src/main/java/dev/learning/xapi/model/Statement.java
+++ b/xapi-model/src/main/java/dev/learning/xapi/model/Statement.java
@@ -17,6 +17,7 @@
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.time.Instant;
+import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.function.Consumer;
@@ -318,6 +319,42 @@ public Builder context(Context context) {
return this;
}
+ /**
+ * Adds an attachment.
+ *
+ * @param attachment An {@link Attachment} object.
+ *
+ * @return This builder
+ *
+ * @see Statement#attachments
+ */
+ public Builder addAttachment(Attachment attachment) {
+
+ if (this.attachments == null) {
+ this.attachments = new ArrayList<>();
+ }
+
+ this.attachments.add(attachment);
+ return this;
+ }
+
+ /**
+ * Consumer Builder for attachment.
+ *
+ * @param attachment The Consumer Builder for attachment
+ *
+ * @return This builder
+ *
+ * @see Statement#attachments
+ */
+ public Builder addAttachment(Consumer attachment) {
+
+ final Attachment.Builder builder = Attachment.builder();
+
+ attachment.accept(builder);
+
+ return addAttachment(builder.build());
+ }
}
}
diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/SubStatement.java b/xapi-model/src/main/java/dev/learning/xapi/model/SubStatement.java
index 98fae6ec..0f3c8b13 100644
--- a/xapi-model/src/main/java/dev/learning/xapi/model/SubStatement.java
+++ b/xapi-model/src/main/java/dev/learning/xapi/model/SubStatement.java
@@ -10,6 +10,7 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
+import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import lombok.Builder;
@@ -159,6 +160,42 @@ public Builder verb(Verb verb) {
return this;
}
+ /**
+ * Consumer Builder for attachment.
+ *
+ * @param attachment The Consumer Builder for attachment
+ *
+ * @return This builder
+ *
+ * @see SubStatement#attachments
+ */
+ public Builder addAttachment(Consumer attachment) {
+
+ final Attachment.Builder builder = Attachment.builder();
+
+ attachment.accept(builder);
+
+ return addAttachment(builder.build());
+ }
+
+ /**
+ * Adds an attachment.
+ *
+ * @param attachment An {@link Attachment} object.
+ *
+ * @return This builder
+ *
+ * @see SubStatement#attachments
+ */
+ public Builder addAttachment(Attachment attachment) {
+
+ if (this.attachments == null) {
+ this.attachments = new ArrayList<>();
+ }
+
+ this.attachments.add(attachment);
+ return this;
+ }
}
}
diff --git a/xapi-model/src/test/java/dev/learning/xapi/model/AttachmentTests.java b/xapi-model/src/test/java/dev/learning/xapi/model/AttachmentTests.java
index cf464b1c..88825e6e 100644
--- a/xapi-model/src/test/java/dev/learning/xapi/model/AttachmentTests.java
+++ b/xapi-model/src/test/java/dev/learning/xapi/model/AttachmentTests.java
@@ -9,15 +9,17 @@
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertNull;
-import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
-import java.io.File;
+import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
import java.util.Locale;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
@@ -29,6 +31,8 @@
*
* @author Lukáš Sahula
* @author Martin Myslik
+ * @author Thomas Turrell-Croft
+ * @author István Rátkai (Selindek)
*/
@DisplayName("Attachment tests")
class AttachmentTests {
@@ -38,12 +42,12 @@ class AttachmentTests {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
- void whenDeserializingActivityDefinitionThenResultIsInstanceOfAttachment() throws Exception {
+ void whenDeserializingAttachmentThenResultIsInstanceOfAttachment() throws Exception {
- final File file = ResourceUtils.getFile("classpath:attachment/attachment.json");
+ final var file = ResourceUtils.getFile("classpath:attachment/attachment.json");
- // When Deserializing ActivityDefinition
- final Attachment result = objectMapper.readValue(file, Attachment.class);
+ // When Deserializing Attachment
+ final var result = objectMapper.readValue(file, Attachment.class);
// Then Result Is Instance Of Attachment
assertThat(result, instanceOf(Attachment.class));
@@ -51,12 +55,12 @@ void whenDeserializingActivityDefinitionThenResultIsInstanceOfAttachment() throw
}
@Test
- void whenDeserializingActivityDefinitionThenUsageTypeIsExpected() throws Exception {
+ void whenDeserializingAttachmentThenUsageTypeIsExpected() throws Exception {
- final File file = ResourceUtils.getFile("classpath:attachment/attachment.json");
+ final var file = ResourceUtils.getFile("classpath:attachment/attachment.json");
- // When Deserializing ActivityDefinition
- final Attachment result = objectMapper.readValue(file, Attachment.class);
+ // When Deserializing Attachment
+ final var result = objectMapper.readValue(file, Attachment.class);
// Then UsageType Is Expected
assertThat(result.getUsageType(),
@@ -65,12 +69,12 @@ void whenDeserializingActivityDefinitionThenUsageTypeIsExpected() throws Excepti
}
@Test
- void whenDeserializingActivityDefinitionThenDisplayIsExpected() throws Exception {
+ void whenDeserializingAttachmentThenDisplayIsExpected() throws Exception {
- final File file = ResourceUtils.getFile("classpath:attachment/attachment.json");
+ final var file = ResourceUtils.getFile("classpath:attachment/attachment.json");
- // When Deserializing ActivityDefinition
- final Attachment result = objectMapper.readValue(file, Attachment.class);
+ // When Deserializing Attachment
+ final var result = objectMapper.readValue(file, Attachment.class);
// Then Display Is Expected
assertThat(result.getDisplay().get(Locale.US), is("Signature"));
@@ -78,12 +82,12 @@ void whenDeserializingActivityDefinitionThenDisplayIsExpected() throws Exception
}
@Test
- void whenDeserializingActivityDefinitionThenDescriptionIsExpected() throws Exception {
+ void whenDeserializingAttachmentThenDescriptionIsExpected() throws Exception {
- final File file = ResourceUtils.getFile("classpath:attachment/attachment.json");
+ final var file = ResourceUtils.getFile("classpath:attachment/attachment.json");
- // When Deserializing ActivityDefinition
- final Attachment result = objectMapper.readValue(file, Attachment.class);
+ // When Deserializing Attachment
+ final var result = objectMapper.readValue(file, Attachment.class);
// Then Description Is Expected
assertThat(result.getDescription().get(Locale.US), is("A test signature"));
@@ -91,12 +95,12 @@ void whenDeserializingActivityDefinitionThenDescriptionIsExpected() throws Excep
}
@Test
- void whenDeserializingActivityDefinitionThenContentTypeIsExpected() throws Exception {
+ void whenDeserializingAttachmentThenContentTypeIsExpected() throws Exception {
- final File file = ResourceUtils.getFile("classpath:attachment/attachment.json");
+ final var file = ResourceUtils.getFile("classpath:attachment/attachment.json");
- // When Deserializing ActivityDefinition
- final Attachment result = objectMapper.readValue(file, Attachment.class);
+ // When Deserializing Attachment
+ final var result = objectMapper.readValue(file, Attachment.class);
// Then ContentType Is Expected
assertThat(result.getContentType(), is("application/octet-stream"));
@@ -104,12 +108,12 @@ void whenDeserializingActivityDefinitionThenContentTypeIsExpected() throws Excep
}
@Test
- void whenDeserializingActivityDefinitionThenLengthIsExpected() throws Exception {
+ void whenDeserializingAttachmentThenLengthIsExpected() throws Exception {
- final File file = ResourceUtils.getFile("classpath:attachment/attachment.json");
+ final var file = ResourceUtils.getFile("classpath:attachment/attachment.json");
- // When Deserializing ActivityDefinition
- final Attachment result = objectMapper.readValue(file, Attachment.class);
+ // When Deserializing Attachment
+ final var result = objectMapper.readValue(file, Attachment.class);
// Then Length Is Expected
assertThat(result.getLength(), is(4235));
@@ -117,12 +121,12 @@ void whenDeserializingActivityDefinitionThenLengthIsExpected() throws Exception
}
@Test
- void whenDeserializingActivityDefinitionThenSha2IsExpected() throws Exception {
+ void whenDeserializingAttachmentThenSha2IsExpected() throws Exception {
- final File file = ResourceUtils.getFile("classpath:attachment/attachment.json");
+ final var file = ResourceUtils.getFile("classpath:attachment/attachment.json");
- // When Deserializing ActivityDefinition
- final Attachment result = objectMapper.readValue(file, Attachment.class);
+ // When Deserializing Attachment
+ final var result = objectMapper.readValue(file, Attachment.class);
// Then Sha2 Is Expected
assertThat(result.getSha2(),
@@ -131,12 +135,12 @@ void whenDeserializingActivityDefinitionThenSha2IsExpected() throws Exception {
}
@Test
- void whenDeserializingActivityDefinitionThenFileUrlIsExpected() throws Exception {
+ void whenDeserializingAttachmentThenFileUrlIsExpected() throws Exception {
- final File file = ResourceUtils.getFile("classpath:attachment/attachment.json");
+ final var file = ResourceUtils.getFile("classpath:attachment/attachment.json");
- // When Deserializing ActivityDefinition
- final Attachment result = objectMapper.readValue(file, Attachment.class);
+ // When Deserializing Attachment
+ final var result = objectMapper.readValue(file, Attachment.class);
// Then FileUrl Is Expected
assertThat(result.getFileUrl(), is(URI.create("https://example.com")));
@@ -146,7 +150,7 @@ void whenDeserializingActivityDefinitionThenFileUrlIsExpected() throws Exception
@Test
void whenSerializingAttachmentThenResultIsEqualToExpectedJson() throws IOException {
- final Attachment attachment = Attachment.builder()
+ final var attachment = Attachment.builder()
.usageType(URI.create("http://adlnet.gov/expapi/attachments/signature"))
@@ -165,7 +169,7 @@ void whenSerializingAttachmentThenResultIsEqualToExpectedJson() throws IOExcepti
.build();
// When Serializing Attachment
- final JsonNode result = objectMapper.readTree(objectMapper.writeValueAsString(attachment));
+ final var result = objectMapper.readTree(objectMapper.writeValueAsString(attachment));
// Then Result Is Equal To Expected Json
assertThat(result,
@@ -177,15 +181,15 @@ void whenSerializingAttachmentThenResultIsEqualToExpectedJson() throws IOExcepti
@Test
void whenCallingToStringThenResultIsExpected() throws IOException {
- final Attachment attachment = objectMapper
+ final var attachment = objectMapper
.readValue(ResourceUtils.getFile("classpath:attachment/attachment.json"), Attachment.class);
// When Calling ToString
- final String result = attachment.toString();
+ final var result = attachment.toString();
// Then Result Is Expected
assertThat(result, is(
- "Attachment(usageType=http://adlnet.gov/expapi/attachments/signature, display={en_US=Signature}, description={en_US=A test signature}, contentType=application/octet-stream, length=4235, sha2=672fa5fa658017f1b72d65036f13379c6ab05d4ab3b6664908d8acf0b6a0c634, fileUrl=https://example.com)"));
+ "Attachment(usageType=http://adlnet.gov/expapi/attachments/signature, display={en_US=Signature}, description={en_US=A test signature}, contentType=application/octet-stream, length=4235, sha2=672fa5fa658017f1b72d65036f13379c6ab05d4ab3b6664908d8acf0b6a0c634, fileUrl=https://example.com, content=null)"));
}
@@ -193,11 +197,166 @@ void whenCallingToStringThenResultIsExpected() throws IOException {
* Builder Tests
*/
+ @Test
+ void whenBuildingAttachmentWithDataThenDataIsSet() {
+
+ // When Building Attachment With Data
+ final var attachment = Attachment.builder()
+
+ .usageType(URI.create("http://adlnet.gov/expapi/attachments/text"))
+
+ .addDisplay(Locale.US, "Text")
+
+ .contentType("plain/text")
+
+ .length(4)
+
+ .content("text")
+
+ .fileUrl(URI.create("https://example.com"))
+
+ .build();
+
+ // Then Data Is Set
+ assertThat(new String(attachment.getContent(), StandardCharsets.UTF_8), is("text"));
+
+ }
+
+ @Test
+ void givenAttachmentWithStringDataWhenGettingSHA2ThenResultIsExpected() {
+
+ // Given Attachment With String Data
+ final var attachment = Attachment.builder()
+
+ .content("Simple attachment").length(17)
+
+ .contentType("text/plain")
+
+ .usageType(URI.create("https://example.com/attachments/greeting"))
+
+ .addDisplay(Locale.ENGLISH, "text attachment")
+
+ .build();
+
+ // When Getting SHA2
+ final var result = attachment.getSha2();
+
+ // Then Result Is Expected
+ assertThat(result, is("b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5"));
+
+ }
+
+ @Test
+ void givenAttachmentWithBinaryDataWhenGettingSHA2ThenResultIsExpected()
+ throws FileNotFoundException, IOException {
+
+ final var data =
+ Files.readAllBytes(ResourceUtils.getFile("classpath:attachment/example.jpg").toPath());
+
+ // Given Attachment With Binary Data
+ final var attachment = Attachment.builder()
+
+ .content(data).length(data.length)
+
+ .contentType("image/jpeg")
+
+ .usageType(URI.create("https://example.com/attachments/greeting"))
+
+ .addDisplay(Locale.ENGLISH, "JPEG attachment")
+
+ .build();
+
+ // When Getting SHA2
+ final var result = attachment.getSha2();
+
+ // Then Result Is Expected
+ assertThat(result, is("27c7a7c1e3d2ff43e4ee1a8915fef351d1ef75d5aeff873e9b2893f4589dcdcc"));
+
+ }
+
+ @Test
+ void whenBuildingAttachmentWithDataAndSha2ThenSha2IsTheCalculatedOne() {
+
+ // When Building Attachment With Data And Sha2
+ final var attachment = Attachment.builder()
+
+ .usageType(URI.create("http://adlnet.gov/expapi/attachments/text"))
+
+ .addDisplay(Locale.US, "Text")
+
+ .contentType("plain/text")
+
+ .length(4)
+
+ .content("text")
+
+ .sha2("000000000000000000000000000000000000000000000")
+
+ .fileUrl(URI.create("https://example.com"))
+
+ .build();
+
+ // Then Sha2 Is Set Is The Calculated One
+ assertThat(attachment.getSha2(),
+ is("982d9e3eb996f559e633f4d194def3761d909f5a3b647d1a851fead67c32c9d1"));
+
+ }
+
+ @Test
+ void whenBuildingAttachmentWithNullByteArrayContentThenSha2IsNull() {
+
+ // When Building Attachment With Null Byte Array Content
+ final var attachment = Attachment.builder()
+
+ .usageType(URI.create("http://adlnet.gov/expapi/attachments/text"))
+
+ .addDisplay(Locale.US, "Text")
+
+ .contentType("plain/text")
+
+ .length(4)
+
+ .content((byte[]) null)
+
+ .fileUrl(URI.create("https://example.com"))
+
+ .build();
+
+ // Then Sha2 Is Null
+ assertNull(attachment.getSha2());
+
+ }
+
+ @Test
+ void whenBuildingAttachmentWithNullStringContentThenSha2IsNull() {
+
+ // When Building Attachment With Null String Content
+ final var attachment = Attachment.builder()
+
+ .usageType(URI.create("http://adlnet.gov/expapi/attachments/text"))
+
+ .addDisplay(Locale.US, "Text")
+
+ .contentType("plain/text")
+
+ .length(4)
+
+ .content((String) null)
+
+ .fileUrl(URI.create("https://example.com"))
+
+ .build();
+
+ // Then Sha2 Is Null
+ assertNull(attachment.getSha2());
+
+ }
+
@Test
void whenBuildingAttachmentWithTwoDisplayValuesThenDisplayLanguageMapHasTwoEntries() {
// When Building Attachment With Two Display Values
- final Attachment attachment = Attachment.builder()
+ final var attachment = Attachment.builder()
.usageType(URI.create("http://adlnet.gov/expapi/attachments/signature"))
@@ -226,7 +385,7 @@ void whenBuildingAttachmentWithTwoDisplayValuesThenDisplayLanguageMapHasTwoEntri
void whenBuildingAttachmentWithTwoDescriptionValuesThenDisplayLanguageMapHasTwoEntries() {
// When Building Attachment With Two Description Values
- final Attachment attachment = Attachment.builder()
+ final var attachment = Attachment.builder()
.usageType(URI.create("http://adlnet.gov/expapi/attachments/signature"))
@@ -254,8 +413,7 @@ void whenBuildingAttachmentWithTwoDescriptionValuesThenDisplayLanguageMapHasTwoE
@Test
void whenValidatingAttachmentWithAllRequiredPropertiesThenConstraintViolationsSizeIsZero() {
-
- final Attachment attachment = Attachment.builder()
+ final var attachment = Attachment.builder()
.usageType(URI.create("http://adlnet.gov/expapi/attachments/signature"))
@@ -285,7 +443,7 @@ void whenValidatingAttachmentWithAllRequiredPropertiesThenConstraintViolationsSi
@Test
void whenValidatingAttachmentWithoutUsageTypeThenConstraintViolationsSizeIsOne() {
- final Attachment attachment = Attachment.builder()
+ final var attachment = Attachment.builder()
.addDisplay(Locale.US, "Signature")
@@ -314,7 +472,7 @@ void whenValidatingAttachmentWithoutUsageTypeThenConstraintViolationsSizeIsOne()
void whenValidatingAttachmentWithoutDisplayThenConstraintViolationsSizeIsOne() {
- final Attachment attachment = Attachment.builder()
+ final var attachment = Attachment.builder()
.usageType(URI.create("http://adlnet.gov/expapi/attachments/signature"))
@@ -342,7 +500,7 @@ void whenValidatingAttachmentWithoutDisplayThenConstraintViolationsSizeIsOne() {
@Test
void whenValidatingAttachmentWithoutContentTypeThenConstraintViolationsSizeIsOne() {
- final Attachment attachment = Attachment.builder()
+ final var attachment = Attachment.builder()
.usageType(URI.create("http://adlnet.gov/expapi/attachments/signature"))
@@ -370,7 +528,7 @@ void whenValidatingAttachmentWithoutContentTypeThenConstraintViolationsSizeIsOne
@Test
void whenValidatingAttachmentWithoutSha2ThenConstraintViolationsSizeIsOne() {
- final Attachment attachment = Attachment.builder()
+ final var attachment = Attachment.builder()
.usageType(URI.create("http://adlnet.gov/expapi/attachments/signature"))
@@ -398,7 +556,7 @@ void whenValidatingAttachmentWithoutSha2ThenConstraintViolationsSizeIsOne() {
@Test
void whenValidatingAttachmentWithoutLengthThenConstraintViolationsSizeIsOne() {
- final Attachment attachment = Attachment.builder()
+ final var attachment = Attachment.builder()
.usageType(URI.create("http://adlnet.gov/expapi/attachments/signature"))
diff --git a/xapi-model/src/test/java/dev/learning/xapi/model/StatementTests.java b/xapi-model/src/test/java/dev/learning/xapi/model/StatementTests.java
index 52bf14b8..bff16ef0 100644
--- a/xapi-model/src/test/java/dev/learning/xapi/model/StatementTests.java
+++ b/xapi-model/src/test/java/dev/learning/xapi/model/StatementTests.java
@@ -577,5 +577,109 @@ void whenValidatingStatementWithSubStatementWithStatementReferenceThenConstraint
}
+ @Test
+ void whenBuildingStatementWithTwoAttachmentsThenAttachmentsHasTwoEntries() {
+
+ // When Building Statement With Two Attachments
+ final LinkedHashMap extensions = new LinkedHashMap<>();
+ extensions.put(URI.create("http://name"), "Kilby");
+
+ final Attachment attachment = Attachment.builder().usageType(URI.create("http://example.com"))
+ .fileUrl(URI.create("http://example.com"))
+
+ .addDisplay(Locale.ENGLISH, "value")
+
+ .addDescription(Locale.ENGLISH, "value")
+
+ .length(123)
+
+ .sha2("123")
+
+ .contentType("file")
+
+ .build();
+
+ final Account account = Account.builder()
+
+ .homePage(URI.create("https://example.com"))
+
+ .name("13936749")
+
+ .build();
+
+
+ final Statement statement = Statement.builder()
+
+ .id(UUID.fromString("4b9175ba-367d-4b93-990b-34d4180039f1"))
+
+ .actor(a -> a.name("A N Other"))
+
+ .verb(v -> v.id(URI.create("http://example.com/xapi/verbs#sent-a-statement"))
+ .addDisplay(Locale.US, "attended"))
+
+ .result(r -> r.success(true).completion(true).response("Response").duration("P1D"))
+
+ .context(c -> c
+
+ .registration(UUID.fromString("ec531277-b57b-4c15-8d91-d292c5b2b8f7"))
+
+ .agentInstructor(a -> a.name("A N Other").account(account))
+
+ .team(t -> t.name("Team").mbox("mailto:team@example.com"))
+
+ .platform("Example virtual meeting software")
+
+ .language(Locale.ENGLISH)
+
+ .statementReference(s -> s.id(UUID.fromString("6690e6c9-3ef0-4ed3-8b37-7f3964730bee")))
+
+ )
+
+ .timestamp(Instant.parse("2013-05-18T05:32:34.804+00:00"))
+
+ .stored(Instant.parse("2013-05-18T05:32:34.804+00:00"))
+
+ .agentAuthority(a -> a.account(account))
+
+ .activityObject(a -> a.id("http://www.example.com/meetings/occurances/34534")
+
+ .definition(d -> d.addName(Locale.UK,
+ "A simple Experience API statement. Note that the LRS does not need to have any prior information about the Actor (learner), the verb, or the Activity/object.")
+
+ .addDescription(Locale.UK,
+ "A simple Experience API statement. Note that the LRS does not need to have any prior information about the Actor (learner), the verb, or the Activity/object.")
+
+ .type(URI.create("http://adlnet.gov/expapi/activities/meeting"))
+
+ .moreInfo(URI.create("http://virtualmeeting.example.com/345256"))
+
+ .extensions(extensions)))
+
+ .addAttachment(attachment)
+
+ .addAttachment(a-> a.usageType(URI.create("http://example.com"))
+
+ .fileUrl(URI.create("http://example.com/2"))
+
+ .addDisplay(Locale.ENGLISH, "value2")
+
+ .addDescription(Locale.ENGLISH, "value2")
+
+ .length(1234)
+
+ .sha2("1234")
+
+ .contentType("file")
+
+ )
+
+ .version("1.0.0")
+
+ .build();
+
+ // Then Attachments Has Two Entries
+ assertThat(statement.getAttachments(), hasSize(2));
+
+ }
}
diff --git a/xapi-model/src/test/java/dev/learning/xapi/model/SubStatementTests.java b/xapi-model/src/test/java/dev/learning/xapi/model/SubStatementTests.java
index da8eb198..c5e8c560 100644
--- a/xapi-model/src/test/java/dev/learning/xapi/model/SubStatementTests.java
+++ b/xapi-model/src/test/java/dev/learning/xapi/model/SubStatementTests.java
@@ -325,7 +325,21 @@ void whenSerializingSubStatementThenResultIsEqualToExpectedJson() throws IOExcep
.context(context)
- .attachments(Collections.singletonList(attachment))
+ .addAttachment(attachment)
+
+ .addAttachment(a->a.usageType(URI.create("http://example.com"))
+
+ .fileUrl(URI.create("http://example.com"))
+
+ .addDisplay(Locale.ENGLISH, "value")
+
+ .addDescription(Locale.ENGLISH, "value")
+
+ .length(123)
+
+ .sha2("123")
+
+ .contentType("file"))
.build();
@@ -352,7 +366,7 @@ void whenCallingToStringThenResultIsExpected() throws IOException {
// Then Result Is Expected
assertThat(result, is(
- "SubStatement(actor=Agent(super=Actor(name=null, mbox=mailto:agent@example.com, mboxSha1sum=null, openid=null, account=null)), verb=Verb(id=http://example.com/confirmed, display={en_US=confirmed}), object=StatementReference(id=9e13cefd-53d3-4eac-b5ed-2cf6693903bb), result=Result(score=Score(scaled=1.0, raw=1.0, min=0.0, max=5.0), success=true, completion=true, response=test, duration=P1D, extensions=null), context=Context(registration=6d969975-8d7e-4506-ac19-877c57f2921a, instructor=Agent(super=Actor(name=null, mbox=mailto:agent@example.com, mboxSha1sum=null, openid=null, account=null)), team=Group(super=Actor(name=Example Group, mbox=null, mboxSha1sum=null, openid=null, account=null), member=null), contextActivities=ContextActivities(parent=[Activity(id=http://www.example.co.uk/exampleactivity, definition=null)], grouping=[Activity(id=http://www.example.co.uk/exampleactivity, definition=null)], category=[Activity(id=http://www.example.co.uk/exampleactivity, definition=null)], other=[Activity(id=http://www.example.co.uk/exampleactivity, definition=null)]), revision=revision, platform=platform, language=en_US, statement=StatementReference(id=9e13cefd-53d3-4eac-b5ed-2cf6693903bb), extensions={http://url=www.example.com}), timestamp=2015-11-18T11:17:00Z, attachments=[Attachment(usageType=http://example.com, display={en_US=value}, description={en_US=value}, contentType=file, length=123, sha2=123, fileUrl=http://example.com)])"));
+ "SubStatement(actor=Agent(super=Actor(name=null, mbox=mailto:agent@example.com, mboxSha1sum=null, openid=null, account=null)), verb=Verb(id=http://example.com/confirmed, display={en_US=confirmed}), object=StatementReference(id=9e13cefd-53d3-4eac-b5ed-2cf6693903bb), result=Result(score=Score(scaled=1.0, raw=1.0, min=0.0, max=5.0), success=true, completion=true, response=test, duration=P1D, extensions=null), context=Context(registration=6d969975-8d7e-4506-ac19-877c57f2921a, instructor=Agent(super=Actor(name=null, mbox=mailto:agent@example.com, mboxSha1sum=null, openid=null, account=null)), team=Group(super=Actor(name=Example Group, mbox=null, mboxSha1sum=null, openid=null, account=null), member=null), contextActivities=ContextActivities(parent=[Activity(id=http://www.example.co.uk/exampleactivity, definition=null)], grouping=[Activity(id=http://www.example.co.uk/exampleactivity, definition=null)], category=[Activity(id=http://www.example.co.uk/exampleactivity, definition=null)], other=[Activity(id=http://www.example.co.uk/exampleactivity, definition=null)]), revision=revision, platform=platform, language=en_US, statement=StatementReference(id=9e13cefd-53d3-4eac-b5ed-2cf6693903bb), extensions={http://url=www.example.com}), timestamp=2015-11-18T11:17:00Z, attachments=[Attachment(usageType=http://example.com, display={en_US=value}, description={en_US=value}, contentType=file, length=123, sha2=123, fileUrl=http://example.com, content=null), Attachment(usageType=http://example.com, display={en=value}, description={en=value}, contentType=file, length=123, sha2=123, fileUrl=http://example.com, content=null)])"));
}
diff --git a/xapi-model/src/test/resources/attachment/example.jpg b/xapi-model/src/test/resources/attachment/example.jpg
new file mode 100644
index 00000000..82123354
Binary files /dev/null and b/xapi-model/src/test/resources/attachment/example.jpg differ
diff --git a/xapi-model/src/test/resources/sub_statement/sub_statement.json b/xapi-model/src/test/resources/sub_statement/sub_statement.json
index a4db3e02..234e4dda 100644
--- a/xapi-model/src/test/resources/sub_statement/sub_statement.json
+++ b/xapi-model/src/test/resources/sub_statement/sub_statement.json
@@ -78,5 +78,18 @@
"length" : 123,
"sha2" : "123",
"fileUrl" : "http://example.com"
+ },
+ {
+ "usageType" : "http://example.com",
+ "display" : {
+ "en" : "value"
+ },
+ "description" : {
+ "en" : "value"
+ },
+ "contentType" : "file",
+ "length" : 123,
+ "sha2" : "123",
+ "fileUrl" : "http://example.com"
} ]
}