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" } ] }