From 688d5c8559e54bed8e2e31ceeafb0fe2b3190598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Fri, 10 Mar 2023 10:57:13 +0000 Subject: [PATCH 01/27] refactor client --- .../learning/xapi/client/PostStatementsRequest.java | 3 +-- .../main/java/dev/learning/xapi/client/XapiClient.java | 10 +++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java index 8e9751d9..4b31d8f4 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java @@ -5,7 +5,6 @@ package dev.learning.xapi.client; import dev.learning.xapi.model.Statement; -import java.util.Arrays; import java.util.List; import java.util.Map; import lombok.Builder; @@ -70,7 +69,7 @@ public Builder statements(List statements) { * * @see PostStatementsRequest#statements */ - public Builder statements(Statement... statements) { + public Builder statements(List statements) { this.statements = Arrays.asList(statements); return this; } 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 1c122478..1375dc0f 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 @@ -40,7 +40,15 @@ public class XapiClient { private static final ParameterizedTypeReference> LIST_STRING_TYPE = new ParameterizedTypeReference<>() {}; - /** + private static final ParameterizedTypeReference< + List> LIST_UUID_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 From f9d45234d156c57780122fdf409db3b4b05e5593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Fri, 10 Mar 2023 10:59:58 +0000 Subject: [PATCH 02/27] fcs --- .../src/main/java/dev/learning/xapi/client/XapiClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1375dc0f..987f7781 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 @@ -48,7 +48,7 @@ public class XapiClient { 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 From 047d6ded59ae7d5a8f37b58d6381a733e13647db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Fri, 10 Mar 2023 11:15:58 +0000 Subject: [PATCH 03/27] fixup --- .../java/dev/learning/xapi/client/PostStatementsRequest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java index 4b31d8f4..5fc1fb89 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java @@ -5,6 +5,7 @@ package dev.learning.xapi.client; import dev.learning.xapi.model.Statement; +import java.util.Arrays; import java.util.List; import java.util.Map; import lombok.Builder; @@ -70,7 +71,7 @@ public Builder statements(List statements) { * @see PostStatementsRequest#statements */ public Builder statements(List statements) { - this.statements = Arrays.asList(statements); + this.statements = statements; return this; } From aab9d97f9184d5c2df85c4bd8315ff03ef78f1be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Fri, 10 Mar 2023 09:27:35 +0000 Subject: [PATCH 04/27] SOME PROGRESS --- .../learning/xapi/client/MultipartHelper.java | 95 +++++++++++++++++++ .../dev/learning/xapi/client/XapiClient.java | 63 +++--------- .../dev/learning/xapi/model/Attachment.java | 4 + 3 files changed, 114 insertions(+), 48 deletions(-) create mode 100644 xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java new file mode 100644 index 00000000..15e7346b --- /dev/null +++ b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java @@ -0,0 +1,95 @@ +/* + * 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.Statement; +import java.util.Arrays; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +/** + * Helper methods for creating multipart message from statements. + * + * @author István Rátkai (Selindek) + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class MultipartHelper { + + private static final String MULTIPART_BOUNDARY = "xapi.learning.dev"; + 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_END = BODY_SEPARATOR + BOUNDARY_PREFIX; + + public static final MediaType MULTIPART_MEDIATYPE = MediaType.valueOf(MULTIPART_CONTENT_TYPE); + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Checks if a {@link Statement} has at least one attachment with data. + * + * @param statement a {@link Statement} object + * @return whether the {@link Statement} has at least attachment with data. + */ + public static boolean hasAttachments(Statement statement) { + final var attachments = statement.getAttachments(); + + return attachments != null && Arrays.stream(attachments).anyMatch(a -> a.getData() != null); + } + + /** + * Checks if any {@link Statement}s in an array has at least one attachment with data. + * + * @param statement an array of {@link Statement}s + * @return whether the any {@link Statement}s in an array has at least one attachment with data. + */ + public static boolean hasAttachments(Statement[] statements) { + + return Arrays.stream(statements).filter(s -> s.getAttachments() != null) + .flatMap(s -> Arrays.stream(s.getAttachments())).anyMatch(a -> a.getData() != null); + } + + /** + * Creates an xAPI multipart/mixed body from a {@link Statement}. + * + * @param statement a {@link Statement} object. + * @return the body as a String + */ + @SneakyThrows + public static String createMultipartBody(Statement statement) { + var body = new StringBuilder(); + // Header of first part + body.append(HttpHeaders.CONTENT_TYPE).append(':').append(MediaType.APPLICATION_JSON_VALUE) + .append(CRLF); + body.append(CRLF); + // Body of first part + body.append(objectMapper.writeValueAsString(statement)); + +// var getAttachments(statement) +// writeAttachments(body, attachments); + + System.err.println(body.toString()); + return body.toString(); + } + + /** + * Creates an xAPI multipart/mixed body from an array of {@link Statement}s. + * + * @param statement an array of {@link Statement}s. + * @return the body as a String + */ + public static String createMultipartBody(Statement[] statements) { + final var body = new StringBuilder(); + + return body.toString(); + } + +} 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 987f7781..c6b927cd 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 @@ -25,7 +25,6 @@ * * @author Thomas Turrell-Croft * @author István Rátkai (Selindek) - * * @see xAPI * communication resources @@ -52,7 +51,7 @@ public class XapiClient { * 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) { this.webClient = builder @@ -123,15 +122,23 @@ 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()) + if (MultipartHelper.hasAttachments(request.getStatement())) { + // has at least one attachment with actual data + requestSpec.contentType(MultipartHelper.MULTIPART_MEDIATYPE); + // construct whole multipart body manually + requestSpec.bodyValue("{}"); + } else { + // add body directly + requestSpec.bodyValue(request.getStatement()); + } - .retrieve() + return requestSpec.retrieve() .toEntity(LIST_UUID_TYPE) @@ -269,7 +276,6 @@ public Mono> getStatements() { *

* * @param request The parameters of the get statements request - * * @return the ResponseEntity */ public Mono> getStatements(GetStatementsRequest request) { @@ -296,7 +302,6 @@ public Mono> getStatements(GetStatementsRequest *

* * @param request The Consumer Builder for the get statements request - * * @return the ResponseEntity */ public Mono> getStatements( @@ -318,7 +323,6 @@ public Mono> getStatements( *

* * @param request The parameters of the get more statements request - * * @return the ResponseEntity */ public Mono> getMoreStatements(GetMoreStatementsRequest request) { @@ -345,7 +349,6 @@ public Mono> getMoreStatements(GetMoreStatements *

* * @param request The Consumer Builder for the get more statements request - * * @return the ResponseEntity */ public Mono> getMoreStatements( @@ -369,7 +372,6 @@ public Mono> getMoreStatements( *

* * @param request The parameters of the get state request - * * @return the ResponseEntity */ public Mono> getState(GetStateRequest request, Class bodyType) { @@ -396,7 +398,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, @@ -418,7 +419,6 @@ public Mono> getState(Consumer * * @param request The parameters of the post state request - * * @return the ResponseEntity */ public Mono> postState(PostStateRequest request) { @@ -449,7 +449,6 @@ public Mono> postState(PostStateRequest request) { *

* * @param request The Consumer Builder for the post state request - * * @return the ResponseEntity */ public Mono> postState(Consumer> request) { @@ -470,7 +469,6 @@ public Mono> postState(Consumer * * @param request The parameters of the put state request - * * @return the ResponseEntity */ public Mono> putState(PutStateRequest request) { @@ -501,7 +499,6 @@ public Mono> putState(PutStateRequest request) { *

* * @param request The Consumer Builder for the put state request - * * @return the ResponseEntity */ public Mono> putState(Consumer> request) { @@ -522,7 +519,6 @@ public Mono> putState(Consumer * * @param request The parameters of the delete state request - * * @return the ResponseEntity */ public Mono> deleteState(DeleteStateRequest request) { @@ -549,7 +545,6 @@ public Mono> deleteState(DeleteStateRequest request) { *

* * @param request The Consumer Builder for the delete state request - * * @return the ResponseEntity */ public Mono> deleteState( @@ -568,7 +563,6 @@ public Mono> deleteState( * parameters. * * @param request The parameters of the get states request - * * @return the ResponseEntity */ public Mono>> getStates(GetStatesRequest request) { @@ -595,7 +589,6 @@ public Mono>> getStates(GetStatesRequest request) { *

* * @param request The Consumer Builder for the get states request - * * @return the ResponseEntity */ public Mono>> getStates( @@ -616,7 +609,6 @@ public Mono>> getStates( *

* * @param request The parameters of the delete states request - * * @return the ResponseEntity */ public Mono> deleteStates(DeleteStatesRequest request) { @@ -642,7 +634,6 @@ public Mono> deleteStates(DeleteStatesRequest request) { *

* * @param request The Consumer Builder for the delete states request - * * @return the ResponseEntity */ public Mono> deleteStates( @@ -664,7 +655,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) { @@ -689,7 +679,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) { @@ -708,7 +697,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) { @@ -731,7 +719,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) { @@ -753,7 +740,6 @@ public Mono> getActivity(Consumer * * @param request The parameters of the get agent profile request - * * @return the ResponseEntity */ public Mono> getAgentProfile(GetAgentProfileRequest request, @@ -780,7 +766,6 @@ public Mono> getAgentProfile(GetAgentProfileRequest reques *

* * @param request The Consumer Builder for the get agent profile request - * * @return the ResponseEntity */ public Mono> getAgentProfile( @@ -801,7 +786,6 @@ public Mono> getAgentProfile( *

* * @param request The parameters of the delete agent profile request - * * @return the ResponseEntity */ public Mono> deleteAgentProfile(DeleteAgentProfileRequest request) { @@ -827,7 +811,6 @@ public Mono> deleteAgentProfile(DeleteAgentProfileRequest r *

* * @param request The Consumer Builder for the delete agent profile request - * * @return the ResponseEntity */ public Mono> deleteAgentProfile( @@ -848,7 +831,6 @@ public Mono> deleteAgentProfile( *

* * @param request The parameters of the put agent profile request - * * @return the ResponseEntity */ public Mono> putAgentProfile(PutAgentProfileRequest request) { @@ -878,7 +860,6 @@ public Mono> putAgentProfile(PutAgentProfileRequest request *

* * @param request The Consumer Builder for the put agent profile request - * * @return the ResponseEntity */ public Mono> putAgentProfile( @@ -899,7 +880,6 @@ public Mono> putAgentProfile( *

* * @param request The parameters of the post agent profile request - * * @return the ResponseEntity */ public Mono> postAgentProfile(PostAgentProfileRequest request) { @@ -929,7 +909,6 @@ public Mono> postAgentProfile(PostAgentProfileRequest reque *

* * @param request The Consumer Builder for the post agent profile request - * * @return the ResponseEntity */ public Mono> postAgentProfile( @@ -949,7 +928,6 @@ public Mono> postAgentProfile( * (exclusive). * * @param request The parameters of the get agent profiles request - * * @return the ResponseEntity */ public Mono>> getAgentProfiles(GetAgentProfilesRequest request) { @@ -974,7 +952,6 @@ public Mono>> getAgentProfiles(GetAgentProfilesReque * (exclusive). * * @param request The Consumer Builder for the get agent profiles request - * * @return the ResponseEntity */ public Mono>> getAgentProfiles( @@ -997,7 +974,6 @@ public Mono>> getAgentProfiles( *

* * @param request The parameters of the get activity profile request - * * @return the ResponseEntity */ public Mono> getActivityProfile(GetActivityProfileRequest request, @@ -1024,7 +1000,6 @@ public Mono> getActivityProfile(GetActivityProfileRequest *

* * @param request The Consumer Builder for the get activity profile request - * * @return the ResponseEntity */ public Mono> getActivityProfile( @@ -1045,7 +1020,6 @@ public Mono> getActivityProfile( *

* * @param request The parameters of the post activity profile request - * * @return the ResponseEntity */ public Mono> postActivityProfile(PostActivityProfileRequest request) { @@ -1075,7 +1049,6 @@ public Mono> postActivityProfile(PostActivityProfileRequest *

* * @param request The Consumer Builder for the post activity profile request - * * @return the ResponseEntity */ public Mono> postActivityProfile( @@ -1096,7 +1069,6 @@ public Mono> postActivityProfile( *

* * @param request The parameters of the put activity profile request - * * @return the ResponseEntity */ public Mono> putActivityProfile(PutActivityProfileRequest request) { @@ -1126,7 +1098,6 @@ public Mono> putActivityProfile(PutActivityProfileRequest r *

* * @param request The Consumer Builder for the put activity profile request - * * @return the ResponseEntity */ public Mono> putActivityProfile( @@ -1147,7 +1118,6 @@ public Mono> putActivityProfile( *

* * @param request The parameters of the delete activity profile request - * * @return the ResponseEntity */ public Mono> deleteActivityProfile(DeleteActivityProfileRequest request) { @@ -1173,14 +1143,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); @@ -1197,7 +1166,6 @@ public Mono> deleteActivityProfile( *

* * @param request The parameters of the get activity profiles request - * * @return the ResponseEntity */ public Mono>> getActivityProfiles( @@ -1226,7 +1194,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-model/src/main/java/dev/learning/xapi/model/Attachment.java b/xapi-model/src/main/java/dev/learning/xapi/model/Attachment.java index 6ed363bc..2a07af21 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 @@ -17,6 +17,7 @@ import java.util.Locale; import lombok.Builder; import lombok.EqualsAndHashCode; +import lombok.ToString; import lombok.Value; /** @@ -87,6 +88,9 @@ public class Attachment { // **Warning** do not add fields that are not required by the xAPI specification. + @JsonIgnore + private byte[] data; + /** * Builder for Attachment. */ From 17bccb8e8c129031af58ac763aa7caac078272e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Fri, 10 Mar 2023 16:44:48 +0000 Subject: [PATCH 05/27] working version --- .../learning/xapi/client/MultipartHelper.java | 112 ++++++++++--- .../dev/learning/xapi/client/XapiClient.java | 153 +++++++++++++----- .../learning/xapi/client/XapiClientTests.java | 57 +++++++ .../dev/learning/xapi/model/Attachment.java | 1 + 4 files changed, 263 insertions(+), 60 deletions(-) diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java index 15e7346b..1a45dd2a 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java @@ -5,11 +5,15 @@ package dev.learning.xapi.client; import com.fasterxml.jackson.databind.ObjectMapper; +import dev.learning.xapi.model.Attachment; import dev.learning.xapi.model.Statement; -import java.util.Arrays; +import dev.learning.xapi.model.SubStatement; +import java.util.List; +import java.util.stream.Stream; import lombok.AccessLevel; import lombok.NoArgsConstructor; import lombok.SneakyThrows; +import org.apache.commons.codec.binary.Base64; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -21,40 +25,59 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class MultipartHelper { - private static final String MULTIPART_BOUNDARY = "xapi.learning.dev"; + 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_END = BODY_SEPARATOR + BOUNDARY_PREFIX; + private static final String BODY_FOOTER = BOUNDARY_PREFIX + MULTIPART_BOUNDARY + BOUNDARY_PREFIX; public static final MediaType MULTIPART_MEDIATYPE = MediaType.valueOf(MULTIPART_CONTENT_TYPE); private static final ObjectMapper objectMapper = new ObjectMapper(); /** - * Checks if a {@link Statement} has at least one attachment with data. + * Gets {@link Attachment} of a {@link Statement} which has data property as a {@link Stream}. * * @param statement a {@link Statement} object - * @return whether the {@link Statement} has at least attachment with data. + * @return {@link Attachment} of a {@link Statement} which has data property as a {@link Stream}. */ - public static boolean hasAttachments(Statement statement) { - final var attachments = statement.getAttachments(); + public static Stream getRealAttachments(Statement statement) { - return attachments != null && Arrays.stream(attachments).anyMatch(a -> a.getData() != null); + // 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.getData() != null); } /** - * Checks if any {@link Statement}s in an array has at least one attachment with data. + * Checks if a {@link Statement} has at least one attachment with data property. * - * @param statement an array of {@link Statement}s - * @return whether the any {@link Statement}s in an array has at least one attachment with data. + * @param statement a {@link Statement} object + * @return whether the {@link Statement} has at least attachment with data property. */ - public static boolean hasAttachments(Statement[] statements) { + public static boolean hasRealAttachments(Statement statement) { - return Arrays.stream(statements).filter(s -> s.getAttachments() != null) - .flatMap(s -> Arrays.stream(s.getAttachments())).anyMatch(a -> a.getData() != null); + return getRealAttachments(statement).findAny().isPresent(); + } + + /** + * Checks if any {@link Statement}s in a list has at least one attachment with data property. + * + * @param statement a list of {@link Statement}s + * @return whether any {@link Statement}s in the list has at least one attachment with data + * property. + */ + public static boolean hasRealAttachments(List statements) { + + return statements.stream().anyMatch(MultipartHelper::hasRealAttachments); } /** @@ -66,30 +89,77 @@ public static boolean hasAttachments(Statement[] statements) { @SneakyThrows public static String createMultipartBody(Statement statement) { var body = new StringBuilder(); + // Multipart Boundary + body.append(BODY_SEPARATOR); + // Header of first part body.append(HttpHeaders.CONTENT_TYPE).append(':').append(MediaType.APPLICATION_JSON_VALUE) .append(CRLF); body.append(CRLF); + // Body of first part - body.append(objectMapper.writeValueAsString(statement)); + body.append(objectMapper.writeValueAsString(statement)).append(CRLF); -// var getAttachments(statement) -// writeAttachments(body, attachments); + // Body of attachments + writeAttachments(body, getRealAttachments(statement)); + + // footer + body.append(BODY_FOOTER); - System.err.println(body.toString()); return body.toString(); } /** - * Creates an xAPI multipart/mixed body from an array of {@link Statement}s. + * Creates an xAPI multipart/mixed body from a list of {@link Statement}s. * - * @param statement an array of {@link Statement}s. + * @param statements a list of {@link Statement}s. * @return the body as a String */ - public static String createMultipartBody(Statement[] statements) { + @SneakyThrows + public static String createMultipartBody(List statements) { final var body = new StringBuilder(); + // Multipart Boundary + body.append(BODY_SEPARATOR); + + // Header of first part + body.append(HttpHeaders.CONTENT_TYPE).append(':').append(MediaType.APPLICATION_JSON_VALUE) + .append(CRLF); + body.append(CRLF); + + // Body of first part + body.append(objectMapper.writeValueAsString(statements)).append(CRLF); + + // Body of attachments + writeAttachments(body, statements.stream().flatMap(MultipartHelper::getRealAttachments)); + + // Footer + body.append(BODY_FOOTER); + System.err.println(body.toString()); return body.toString(); } + private static void writeAttachments(StringBuilder body, Stream attachments) { + + // Write identical attachments only once + attachments.distinct().forEach(a -> { + // Multipart Boundary + body.append(BODY_SEPARATOR); + + // Multipart header + body.append(HttpHeaders.CONTENT_TYPE).append(':').append(a.getContentType()).append(CRLF); + body.append("Content-Transfer-Encoding:binary").append(CRLF); + body.append("X-Experience-API-Hash:").append(a.getSha2()).append(CRLF); + body.append(CRLF); + + // Multipart body + if (MediaType.TEXT_PLAIN_VALUE.equals(a.getContentType())) { + body.append(new String(a.getData())).append(CRLF); + } else { + body.append(Base64.decodeBase64(a.getData())).append(CRLF); + } + }); + + } + } 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 c6b927cd..6f67d5ce 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 @@ -25,6 +25,7 @@ * * @author Thomas Turrell-Croft * @author István Rátkai (Selindek) + * * @see xAPI * communication resources @@ -51,7 +52,7 @@ public class XapiClient { * 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) { this.webClient = builder @@ -69,6 +70,7 @@ public XapiClient(WebClient.Builder builder) { /** * Gets a Statement. + * *

* The returned ResponseEntity contains the response headers and the Statement. *

@@ -77,7 +79,7 @@ public XapiClient(WebClient.Builder builder) { */ public Mono> getStatement(GetStatementRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -93,6 +95,7 @@ public Mono> getStatement(GetStatementRequest request) /** * Gets a Statement. + * *

* The returned ResponseEntity contains the response headers and the Statement. *

@@ -112,6 +115,7 @@ public Mono> getStatement( /** * Posts Statement. + * *

* The returned ResponseEntity contains the response headers and the Statement identifier. *

@@ -120,25 +124,17 @@ public Mono> getStatement( */ public Mono> postStatement(PostStatementRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); - final var requestSpec = this.webClient + return this.webClient .method(request.getMethod()) - .uri(u -> request.url(u, queryParams).build(queryParams)); + .uri(u -> request.url(u, queryParams).build(queryParams)) - if (MultipartHelper.hasAttachments(request.getStatement())) { - // has at least one attachment with actual data - requestSpec.contentType(MultipartHelper.MULTIPART_MEDIATYPE); - // construct whole multipart body manually - requestSpec.bodyValue("{}"); - } else { - // add body directly - requestSpec.bodyValue(request.getStatement()); - } + .bodyValue(request.getStatement()) - return requestSpec.retrieve() + .retrieve() .toEntity(LIST_UUID_TYPE) @@ -148,6 +144,7 @@ public Mono> postStatement(PostStatementRequest request) { /** * Posts Statement. + * *

* The returned ResponseEntity contains the response headers and the Statement identifier. *

@@ -166,6 +163,7 @@ public Mono> postStatement(Consumer * The returned ResponseEntity contains the response headers and an array of Statement * identifiers. @@ -175,7 +173,7 @@ public Mono> postStatement(Consumer>> postStatements(PostStatementsRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -193,6 +191,7 @@ public Mono>> postStatements(PostStatementsRequest req /** * Posts Statements. + * *

* The returned ResponseEntity contains the response headers and an array of Statement * identifiers. @@ -213,6 +212,7 @@ public Mono>> postStatements( /** * Gets a voided Statement. + * *

* The returned ResponseEntity contains the response headers and the voided Statement. *

@@ -221,7 +221,7 @@ public Mono>> postStatements( */ public Mono> getVoidedStatement(GetVoidedStatementRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -237,6 +237,7 @@ public Mono> getVoidedStatement(GetVoidedStatementRequ /** * Gets a voided Statement. + * *

* The returned ResponseEntity contains the response headers and the voided Statement. *

@@ -257,6 +258,7 @@ public Mono> getVoidedStatement( /** * Gets a StatementResult object, a list of Statements. If additional results are available, an * URL to retrieve them will be included in the StatementResult Object. + * *

* The returned ResponseEntity contains the response headers and StatementResult. *

@@ -271,16 +273,18 @@ public Mono> getStatements() { /** * Gets a StatementResult object, a list of Statements. If additional results are available, an * URL to retrieve them will be included in the StatementResult Object. + * *

* The returned ResponseEntity contains the response headers and StatementResult. *

* * @param request The parameters of the get statements request + * * @return the ResponseEntity */ public Mono> getStatements(GetStatementsRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -297,11 +301,13 @@ public Mono> getStatements(GetStatementsRequest /** * Gets a StatementResult object, a list of Statements. If additional results are available, an * URL to retrieve them will be included in the StatementResult Object. + * *

* The returned ResponseEntity contains the response headers and StatementResult. *

* * @param request The Consumer Builder for the get statements request + * * @return the ResponseEntity */ public Mono> getStatements( @@ -318,16 +324,18 @@ public Mono> getStatements( /** * Gets a StatementResult object, a list of Statements. If additional results are available, an * URL to retrieve them will be included in the StatementResult Object. + * *

* The returned ResponseEntity contains the response headers and StatementResult. *

* * @param request The parameters of the get more statements request + * * @return the ResponseEntity */ public Mono> getMoreStatements(GetMoreStatementsRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -344,11 +352,13 @@ public Mono> getMoreStatements(GetMoreStatements /** * Gets a StatementResult object, a list of Statements. If additional results are available, an * URL to retrieve them will be included in the StatementResult Object. + * *

* The returned ResponseEntity contains the response headers and StatementResult. *

* * @param request The Consumer Builder for the get more statements request + * * @return the ResponseEntity */ public Mono> getMoreStatements( @@ -367,16 +377,18 @@ public Mono> getMoreStatements( /** * Gets a single document specified by the given stateId activity, agent, and optional * registration. + * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The parameters of the get state request + * * @return the ResponseEntity */ public Mono> getState(GetStateRequest request, Class bodyType) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -393,11 +405,13 @@ public Mono> getState(GetStateRequest request, Class bo /** * Gets a single document specified by the given stateId activity, agent, and optional * registration. + * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The Consumer Builder for the get state request + * * @return the ResponseEntity */ public Mono> getState(Consumer> request, @@ -414,16 +428,18 @@ public Mono> getState(Consumer * The returned ResponseEntity contains the response headers and body. *

* * @param request The parameters of the post state request + * * @return the ResponseEntity */ public Mono> postState(PostStateRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -444,11 +460,13 @@ public Mono> postState(PostStateRequest request) { /** * Posts a single document specified by the given stateId activity, agent, and optional * registration. + * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The Consumer Builder for the post state request + * * @return the ResponseEntity */ public Mono> postState(Consumer> request) { @@ -464,16 +482,18 @@ public Mono> postState(Consumer * The returned ResponseEntity contains the response headers and body. *

* * @param request The parameters of the put state request + * * @return the ResponseEntity */ public Mono> putState(PutStateRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -494,11 +514,13 @@ public Mono> putState(PutStateRequest request) { /** * Puts a single document specified by the given stateId activity, agent, and optional * registration. + * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The Consumer Builder for the put state request + * * @return the ResponseEntity */ public Mono> putState(Consumer> request) { @@ -514,16 +536,18 @@ public Mono> putState(Consumer * The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the delete state request + * * @return the ResponseEntity */ public Mono> deleteState(DeleteStateRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -540,11 +564,13 @@ public Mono> deleteState(DeleteStateRequest request) { /** * Deletes a single document specified by the given stateId activity, agent, and optional * registration. + * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the delete state request + * * @return the ResponseEntity */ public Mono> deleteState( @@ -563,11 +589,12 @@ public Mono> deleteState( * parameters. * * @param request The parameters of the get states request + * * @return the ResponseEntity */ public Mono>> getStates(GetStatesRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -584,11 +611,13 @@ public Mono>> getStates(GetStatesRequest request) { /** * Gets all stateId's specified by the given activityId, agent and optional registration and since * parameters. + * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the get states request + * * @return the ResponseEntity */ public Mono>> getStates( @@ -604,16 +633,18 @@ public Mono>> getStates( /** * Deletes all documents specified by the given activityId, agent and optional registration. + * *

* The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the delete states request + * * @return the ResponseEntity */ public Mono> deleteStates(DeleteStatesRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -629,11 +660,13 @@ public Mono> deleteStates(DeleteStatesRequest request) { /** * Deletes all documents specified by the given activityId, agent and optional registration. + * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the delete states request + * * @return the ResponseEntity */ public Mono> deleteStates( @@ -655,11 +688,12 @@ 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) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -679,6 +713,7 @@ 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) { @@ -697,11 +732,12 @@ 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) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -719,6 +755,7 @@ 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) { @@ -735,17 +772,19 @@ public Mono> getActivity(Consumer * The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the get agent profile request + * * @return the ResponseEntity */ public Mono> getAgentProfile(GetAgentProfileRequest request, Class bodyType) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -761,11 +800,13 @@ public Mono> getAgentProfile(GetAgentProfileRequest reques /** * Gets a single agent profile by the given agent and profileId. + * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the get agent profile request + * * @return the ResponseEntity */ public Mono> getAgentProfile( @@ -781,16 +822,18 @@ public Mono> getAgentProfile( /** * Deletes a single agent profile by the given agent and profileId. + * *

* The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the delete agent profile request + * * @return the ResponseEntity */ public Mono> deleteAgentProfile(DeleteAgentProfileRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -806,11 +849,13 @@ public Mono> deleteAgentProfile(DeleteAgentProfileRequest r /** * Deletes a single agent profile by the given agent and profileId. + * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the delete agent profile request + * * @return the ResponseEntity */ public Mono> deleteAgentProfile( @@ -826,16 +871,18 @@ public Mono> deleteAgentProfile( /** * Puts a single agent profile by the given agent and profileId. + * *

* The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the put agent profile request + * * @return the ResponseEntity */ public Mono> putAgentProfile(PutAgentProfileRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -855,11 +902,13 @@ public Mono> putAgentProfile(PutAgentProfileRequest request /** * Puts a single agent profile by the given agent and profileId. + * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the put agent profile request + * * @return the ResponseEntity */ public Mono> putAgentProfile( @@ -875,16 +924,18 @@ public Mono> putAgentProfile( /** * Posts a single agent profile by the given agent and profileId. + * *

* The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the post agent profile request + * * @return the ResponseEntity */ public Mono> postAgentProfile(PostAgentProfileRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -904,11 +955,13 @@ public Mono> postAgentProfile(PostAgentProfileRequest reque /** * Posts a single agent profile by the given agent and profileId. + * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the post agent profile request + * * @return the ResponseEntity */ public Mono> postAgentProfile( @@ -928,11 +981,12 @@ public Mono> postAgentProfile( * (exclusive). * * @param request The parameters of the get agent profiles request + * * @return the ResponseEntity */ public Mono>> getAgentProfiles(GetAgentProfilesRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -952,6 +1006,7 @@ public Mono>> getAgentProfiles(GetAgentProfilesReque * (exclusive). * * @param request The Consumer Builder for the get agent profiles request + * * @return the ResponseEntity */ public Mono>> getAgentProfiles( @@ -969,17 +1024,19 @@ public Mono>> getAgentProfiles( /** * Fetches the specified Profile document in the context of the specified Activity. + * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The parameters of the get activity profile request + * * @return the ResponseEntity */ public Mono> getActivityProfile(GetActivityProfileRequest request, Class bodyType) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -995,11 +1052,13 @@ public Mono> getActivityProfile(GetActivityProfileRequest /** * Fetches the specified Profile document in the context of the specified Activity. + * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The Consumer Builder for the get activity profile request + * * @return the ResponseEntity */ public Mono> getActivityProfile( @@ -1015,16 +1074,18 @@ public Mono> getActivityProfile( /** * Changes or stores the specified Profile document in the context of the specified Activity. + * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The parameters of the post activity profile request + * * @return the ResponseEntity */ public Mono> postActivityProfile(PostActivityProfileRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -1044,11 +1105,13 @@ public Mono> postActivityProfile(PostActivityProfileRequest /** * Changes or stores the specified Profile document in the context of the specified Activity. + * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The Consumer Builder for the post activity profile request + * * @return the ResponseEntity */ public Mono> postActivityProfile( @@ -1064,16 +1127,18 @@ public Mono> postActivityProfile( /** * Stores the specified Profile document in the context of the specified Activity. + * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The parameters of the put activity profile request + * * @return the ResponseEntity */ public Mono> putActivityProfile(PutActivityProfileRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -1093,11 +1158,13 @@ public Mono> putActivityProfile(PutActivityProfileRequest r /** * Stores the specified Profile document in the context of the specified Activity. + * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The Consumer Builder for the put activity profile request + * * @return the ResponseEntity */ public Mono> putActivityProfile( @@ -1113,16 +1180,18 @@ public Mono> putActivityProfile( /** * Deletes the specified Profile document in the context of the specified Activity. + * *

* The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the delete activity profile request + * * @return the ResponseEntity */ public Mono> deleteActivityProfile(DeleteActivityProfileRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -1138,18 +1207,20 @@ public Mono> deleteActivityProfile(DeleteActivityProfileReq /** * Deletes the specified Profile document in the context of the specified Activity. + * *

* The returned ResponseEntity contains the response headers. *

* * @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); @@ -1161,17 +1232,19 @@ public Mono> deleteActivityProfile( * Fetches Profile ids of all Profile documents for an Activity. If "since" parameter is * specified, this is limited to entries that have been stored or updated since the specified * Timestamp (exclusive). + * *

* The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the get activity profiles request + * * @return the ResponseEntity */ public Mono>> getActivityProfiles( GetActivityProfilesRequest request) { - final Map queryParams = new HashMap<>(); + Map queryParams = new HashMap<>(); return this.webClient @@ -1189,11 +1262,13 @@ public Mono>> getActivityProfiles( * Fetches Profile ids of all Profile documents for an Activity. If "since" parameter is * specified, this is limited to entries that have been stored or updated since the specified * Timestamp (exclusive). + * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the get activity profiles request + * * @return the ResponseEntity */ public Mono>> getActivityProfiles( 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 d06ab622..91fea8ac 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 @@ -6,6 +6,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.Is.is; import static org.hamcrest.core.IsInstanceOf.instanceOf; +import static org.hamcrest.core.StringStartsWith.startsWith; import dev.learning.xapi.model.About; import dev.learning.xapi.model.Activity; @@ -364,6 +365,62 @@ void whenPostingStatementThenContentTypeHeaderIsApplicationJson() throws Interru assertThat(recordedRequest.getHeader("content-type"), is("application/json")); } + @Test + void whenPostingStatementWithAttachmentThenBodyIsExpected() 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.data("Simple attachment") + .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 RecordedRequest 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 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.data("Simple attachment") + .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 RecordedRequest recordedRequest = mockWebServer.takeRequest(); + + // Then Content Type Header Is Multipart Mixed + assertThat(recordedRequest.getHeader("content-type"), startsWith("multipart/mixed")); + } + // Get Voided Statement @Test 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 2a07af21..c4a3b2dd 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 @@ -31,6 +31,7 @@ @Value @Builder @JsonInclude(Include.NON_EMPTY) +@ToString(exclude = "data") @EqualsAndHashCode(of = "sha2") public class Attachment { From 948ce2e3c1bf76b98af8faf949080d81c5d14487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 13 Mar 2023 09:27:04 +0000 Subject: [PATCH 06/27] fixup merge --- .../java/dev/learning/xapi/client/PostStatementsRequest.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java index 5fc1fb89..f9762f78 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java @@ -5,7 +5,6 @@ package dev.learning.xapi.client; import dev.learning.xapi.model.Statement; -import java.util.Arrays; import java.util.List; import java.util.Map; import lombok.Builder; @@ -19,7 +18,6 @@ * @see POST * Statements - * * @author Thomas Turrell-Croft */ @Builder @@ -51,7 +49,6 @@ public static class Builder { * Sets the statements. * * @param statements The statements of the PostStatementsRequest. - * * @return This builder * * @see PostStatementsRequest#statements From c1adaaebbf8b425ddf0685b6a3ef99fd6b350359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 13 Mar 2023 10:12:03 +0000 Subject: [PATCH 07/27] simplify MultipartHelper --- .../learning/xapi/client/MultipartHelper.java | 115 +++++++------ .../dev/learning/xapi/client/XapiClient.java | 155 ++++-------------- 2 files changed, 93 insertions(+), 177 deletions(-) diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java index 1a45dd2a..2c5efcee 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java @@ -16,6 +16,7 @@ import org.apache.commons.codec.binary.Base64; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient.RequestBodySpec; /** * Helper methods for creating multipart message from statements. @@ -38,85 +39,77 @@ public final class MultipartHelper { private static final ObjectMapper objectMapper = new ObjectMapper(); /** - * Gets {@link Attachment} of a {@link Statement} which has data property as a {@link Stream}. + *

+ * 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 statement a {@link Statement} object - * @return {@link Attachment} of a {@link Statement} which has data property as a {@link Stream}. + * @param requestSpec a {@link RequestBodySpec} object. + * @param statement a {@link Statement} to add. */ - public static Stream getRealAttachments(Statement statement) { + public static void addBody(RequestBodySpec requestSpec, 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(); + addBody(requestSpec, statement, getRealAttachments(statement)); - if (statement.getAttachments() != null) { - stream = Stream.concat(stream, statement.getAttachments().stream()); - } - - return stream.filter(a -> a.getData() != null); } /** - * Checks if a {@link Statement} has at least one attachment with data property. + *

+ * 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 statement a {@link Statement} object - * @return whether the {@link Statement} has at least attachment with data property. + * @param requestSpec a {@link RequestBodySpec} object. + * @param statement list of {@link Statement}s to add. */ - public static boolean hasRealAttachments(Statement statement) { + public static void addBody(RequestBodySpec requestSpec, List statements) { + + addBody(requestSpec, statements, + statements.stream().flatMap(MultipartHelper::getRealAttachments)); - return getRealAttachments(statement).findAny().isPresent(); } - /** - * Checks if any {@link Statement}s in a list has at least one attachment with data property. - * - * @param statement a list of {@link Statement}s - * @return whether any {@link Statement}s in the list has at least one attachment with data - * property. - */ - public static boolean hasRealAttachments(List statements) { + public static void addBody(RequestBodySpec requestSpec, Object statements, + Stream attachments) { + + final String attachmentsBody = writeAttachments(attachments); + + if (attachmentsBody.isEmpty()) { + // 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)); + } - return statements.stream().anyMatch(MultipartHelper::hasRealAttachments); } /** - * Creates an xAPI multipart/mixed body from a {@link Statement}. + * Gets {@link Attachment}s of a {@link Statement} which has data property as a {@link Stream}. * - * @param statement a {@link Statement} object. - * @return the body as a String + * @param statement a {@link Statement} object + * @return {@link Attachment} of a {@link Statement} which has data property as a {@link Stream}. */ - @SneakyThrows - public static String createMultipartBody(Statement statement) { - var body = new StringBuilder(); - // Multipart Boundary - body.append(BODY_SEPARATOR); + private static Stream getRealAttachments(Statement statement) { - // Header of first part - body.append(HttpHeaders.CONTENT_TYPE).append(':').append(MediaType.APPLICATION_JSON_VALUE) - .append(CRLF); - body.append(CRLF); - - // Body of first part - body.append(objectMapper.writeValueAsString(statement)).append(CRLF); - - // Body of attachments - writeAttachments(body, getRealAttachments(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(); - // footer - body.append(BODY_FOOTER); + if (statement.getAttachments() != null) { + stream = Stream.concat(stream, statement.getAttachments().stream()); + } - return body.toString(); + return stream.filter(a -> a.getData() != null); } - /** - * Creates an xAPI multipart/mixed body from a list of {@link Statement}s. - * - * @param statements a list of {@link Statement}s. - * @return the body as a String - */ @SneakyThrows - public static String createMultipartBody(List statements) { + private static String createMultipartBody(Object statements, String attachments) { final var body = new StringBuilder(); // Multipart Boundary body.append(BODY_SEPARATOR); @@ -130,16 +123,21 @@ public static String createMultipartBody(List statements) { body.append(objectMapper.writeValueAsString(statements)).append(CRLF); // Body of attachments - writeAttachments(body, statements.stream().flatMap(MultipartHelper::getRealAttachments)); + body.append(attachments); // Footer body.append(BODY_FOOTER); - System.err.println(body.toString()); return body.toString(); } - private static void writeAttachments(StringBuilder body, Stream attachments) { + /* + * Writes distinct attachments. If there are no attachments in the stream then returns an empty + * String. + */ + private static String writeAttachments(Stream attachments) { + + final var body = new StringBuilder(); // Write identical attachments only once attachments.distinct().forEach(a -> { @@ -160,6 +158,7 @@ private static void writeAttachments(StringBuilder body, Stream atta } }); + return body.toString(); } } 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 6f67d5ce..1a4a44d6 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 @@ -25,7 +25,6 @@ * * @author Thomas Turrell-Croft * @author István Rátkai (Selindek) - * * @see xAPI * communication resources @@ -47,12 +46,12 @@ public class XapiClient { 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) { this.webClient = builder @@ -70,7 +69,6 @@ public XapiClient(WebClient.Builder builder) { /** * Gets a Statement. - * *

* The returned ResponseEntity contains the response headers and the Statement. *

@@ -79,7 +77,7 @@ public XapiClient(WebClient.Builder builder) { */ public Mono> getStatement(GetStatementRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -95,7 +93,6 @@ public Mono> getStatement(GetStatementRequest request) /** * Gets a Statement. - * *

* The returned ResponseEntity contains the response headers and the Statement. *

@@ -115,7 +112,6 @@ public Mono> getStatement( /** * Posts Statement. - * *

* The returned ResponseEntity contains the response headers and the Statement identifier. *

@@ -124,17 +120,17 @@ public Mono> getStatement( */ public Mono> postStatement(PostStatementRequest request) { - Map queryParams = new HashMap<>(); + 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()) + MultipartHelper.addBody(requestSpec, request.getStatement()); - .retrieve() + return requestSpec.retrieve() .toEntity(LIST_UUID_TYPE) @@ -144,7 +140,6 @@ public Mono> postStatement(PostStatementRequest request) { /** * Posts Statement. - * *

* The returned ResponseEntity contains the response headers and the Statement identifier. *

@@ -163,7 +158,6 @@ public Mono> postStatement(Consumer * The returned ResponseEntity contains the response headers and an array of Statement * identifiers. @@ -173,17 +167,17 @@ public Mono> postStatement(Consumer>> postStatements(PostStatementsRequest request) { - Map queryParams = new HashMap<>(); + 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()) + MultipartHelper.addBody(requestSpec, request.getStatements()); - .retrieve() + return requestSpec.retrieve() .toEntity(LIST_UUID_TYPE); @@ -191,7 +185,6 @@ public Mono>> postStatements(PostStatementsRequest req /** * Posts Statements. - * *

* The returned ResponseEntity contains the response headers and an array of Statement * identifiers. @@ -212,7 +205,6 @@ public Mono>> postStatements( /** * Gets a voided Statement. - * *

* The returned ResponseEntity contains the response headers and the voided Statement. *

@@ -221,7 +213,7 @@ public Mono>> postStatements( */ public Mono> getVoidedStatement(GetVoidedStatementRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -237,7 +229,6 @@ public Mono> getVoidedStatement(GetVoidedStatementRequ /** * Gets a voided Statement. - * *

* The returned ResponseEntity contains the response headers and the voided Statement. *

@@ -258,7 +249,6 @@ public Mono> getVoidedStatement( /** * Gets a StatementResult object, a list of Statements. If additional results are available, an * URL to retrieve them will be included in the StatementResult Object. - * *

* The returned ResponseEntity contains the response headers and StatementResult. *

@@ -273,18 +263,16 @@ public Mono> getStatements() { /** * Gets a StatementResult object, a list of Statements. If additional results are available, an * URL to retrieve them will be included in the StatementResult Object. - * *

* The returned ResponseEntity contains the response headers and StatementResult. *

* * @param request The parameters of the get statements request - * * @return the ResponseEntity */ public Mono> getStatements(GetStatementsRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -301,13 +289,11 @@ public Mono> getStatements(GetStatementsRequest /** * Gets a StatementResult object, a list of Statements. If additional results are available, an * URL to retrieve them will be included in the StatementResult Object. - * *

* The returned ResponseEntity contains the response headers and StatementResult. *

* * @param request The Consumer Builder for the get statements request - * * @return the ResponseEntity */ public Mono> getStatements( @@ -324,18 +310,16 @@ public Mono> getStatements( /** * Gets a StatementResult object, a list of Statements. If additional results are available, an * URL to retrieve them will be included in the StatementResult Object. - * *

* The returned ResponseEntity contains the response headers and StatementResult. *

* * @param request The parameters of the get more statements request - * * @return the ResponseEntity */ public Mono> getMoreStatements(GetMoreStatementsRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -352,13 +336,11 @@ public Mono> getMoreStatements(GetMoreStatements /** * Gets a StatementResult object, a list of Statements. If additional results are available, an * URL to retrieve them will be included in the StatementResult Object. - * *

* The returned ResponseEntity contains the response headers and StatementResult. *

* * @param request The Consumer Builder for the get more statements request - * * @return the ResponseEntity */ public Mono> getMoreStatements( @@ -377,18 +359,16 @@ public Mono> getMoreStatements( /** * Gets a single document specified by the given stateId activity, agent, and optional * registration. - * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The parameters of the get state request - * * @return the ResponseEntity */ public Mono> getState(GetStateRequest request, Class bodyType) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -405,13 +385,11 @@ public Mono> getState(GetStateRequest request, Class bo /** * Gets a single document specified by the given stateId activity, agent, and optional * registration. - * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The Consumer Builder for the get state request - * * @return the ResponseEntity */ public Mono> getState(Consumer> request, @@ -428,18 +406,16 @@ public Mono> getState(Consumer * The returned ResponseEntity contains the response headers and body. *

* * @param request The parameters of the post state request - * * @return the ResponseEntity */ public Mono> postState(PostStateRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -460,13 +436,11 @@ public Mono> postState(PostStateRequest request) { /** * Posts a single document specified by the given stateId activity, agent, and optional * registration. - * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The Consumer Builder for the post state request - * * @return the ResponseEntity */ public Mono> postState(Consumer> request) { @@ -482,18 +456,16 @@ public Mono> postState(Consumer * The returned ResponseEntity contains the response headers and body. *

* * @param request The parameters of the put state request - * * @return the ResponseEntity */ public Mono> putState(PutStateRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -514,13 +486,11 @@ public Mono> putState(PutStateRequest request) { /** * Puts a single document specified by the given stateId activity, agent, and optional * registration. - * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The Consumer Builder for the put state request - * * @return the ResponseEntity */ public Mono> putState(Consumer> request) { @@ -536,18 +506,16 @@ public Mono> putState(Consumer * The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the delete state request - * * @return the ResponseEntity */ public Mono> deleteState(DeleteStateRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -564,13 +532,11 @@ public Mono> deleteState(DeleteStateRequest request) { /** * Deletes a single document specified by the given stateId activity, agent, and optional * registration. - * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the delete state request - * * @return the ResponseEntity */ public Mono> deleteState( @@ -589,12 +555,11 @@ public Mono> deleteState( * parameters. * * @param request The parameters of the get states request - * * @return the ResponseEntity */ public Mono>> getStates(GetStatesRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -611,13 +576,11 @@ public Mono>> getStates(GetStatesRequest request) { /** * Gets all stateId's specified by the given activityId, agent and optional registration and since * parameters. - * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the get states request - * * @return the ResponseEntity */ public Mono>> getStates( @@ -633,18 +596,16 @@ public Mono>> getStates( /** * Deletes all documents specified by the given activityId, agent and optional registration. - * *

* The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the delete states request - * * @return the ResponseEntity */ public Mono> deleteStates(DeleteStatesRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -660,13 +621,11 @@ public Mono> deleteStates(DeleteStatesRequest request) { /** * Deletes all documents specified by the given activityId, agent and optional registration. - * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the delete states request - * * @return the ResponseEntity */ public Mono> deleteStates( @@ -688,12 +647,11 @@ 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) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -713,7 +671,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) { @@ -732,12 +689,11 @@ 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) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -755,7 +711,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) { @@ -772,19 +727,17 @@ public Mono> getActivity(Consumer * The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the get agent profile request - * * @return the ResponseEntity */ public Mono> getAgentProfile(GetAgentProfileRequest request, Class bodyType) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -800,13 +753,11 @@ public Mono> getAgentProfile(GetAgentProfileRequest reques /** * Gets a single agent profile by the given agent and profileId. - * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the get agent profile request - * * @return the ResponseEntity */ public Mono> getAgentProfile( @@ -822,18 +773,16 @@ public Mono> getAgentProfile( /** * Deletes a single agent profile by the given agent and profileId. - * *

* The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the delete agent profile request - * * @return the ResponseEntity */ public Mono> deleteAgentProfile(DeleteAgentProfileRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -849,13 +798,11 @@ public Mono> deleteAgentProfile(DeleteAgentProfileRequest r /** * Deletes a single agent profile by the given agent and profileId. - * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the delete agent profile request - * * @return the ResponseEntity */ public Mono> deleteAgentProfile( @@ -871,18 +818,16 @@ public Mono> deleteAgentProfile( /** * Puts a single agent profile by the given agent and profileId. - * *

* The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the put agent profile request - * * @return the ResponseEntity */ public Mono> putAgentProfile(PutAgentProfileRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -902,13 +847,11 @@ public Mono> putAgentProfile(PutAgentProfileRequest request /** * Puts a single agent profile by the given agent and profileId. - * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the put agent profile request - * * @return the ResponseEntity */ public Mono> putAgentProfile( @@ -924,18 +867,16 @@ public Mono> putAgentProfile( /** * Posts a single agent profile by the given agent and profileId. - * *

* The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the post agent profile request - * * @return the ResponseEntity */ public Mono> postAgentProfile(PostAgentProfileRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -955,13 +896,11 @@ public Mono> postAgentProfile(PostAgentProfileRequest reque /** * Posts a single agent profile by the given agent and profileId. - * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the post agent profile request - * * @return the ResponseEntity */ public Mono> postAgentProfile( @@ -981,12 +920,11 @@ public Mono> postAgentProfile( * (exclusive). * * @param request The parameters of the get agent profiles request - * * @return the ResponseEntity */ public Mono>> getAgentProfiles(GetAgentProfilesRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -1006,7 +944,6 @@ public Mono>> getAgentProfiles(GetAgentProfilesReque * (exclusive). * * @param request The Consumer Builder for the get agent profiles request - * * @return the ResponseEntity */ public Mono>> getAgentProfiles( @@ -1024,19 +961,17 @@ public Mono>> getAgentProfiles( /** * Fetches the specified Profile document in the context of the specified Activity. - * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The parameters of the get activity profile request - * * @return the ResponseEntity */ public Mono> getActivityProfile(GetActivityProfileRequest request, Class bodyType) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -1052,13 +987,11 @@ public Mono> getActivityProfile(GetActivityProfileRequest /** * Fetches the specified Profile document in the context of the specified Activity. - * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The Consumer Builder for the get activity profile request - * * @return the ResponseEntity */ public Mono> getActivityProfile( @@ -1074,18 +1007,16 @@ public Mono> getActivityProfile( /** * Changes or stores the specified Profile document in the context of the specified Activity. - * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The parameters of the post activity profile request - * * @return the ResponseEntity */ public Mono> postActivityProfile(PostActivityProfileRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -1105,13 +1036,11 @@ public Mono> postActivityProfile(PostActivityProfileRequest /** * Changes or stores the specified Profile document in the context of the specified Activity. - * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The Consumer Builder for the post activity profile request - * * @return the ResponseEntity */ public Mono> postActivityProfile( @@ -1127,18 +1056,16 @@ public Mono> postActivityProfile( /** * Stores the specified Profile document in the context of the specified Activity. - * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The parameters of the put activity profile request - * * @return the ResponseEntity */ public Mono> putActivityProfile(PutActivityProfileRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -1158,13 +1085,11 @@ public Mono> putActivityProfile(PutActivityProfileRequest r /** * Stores the specified Profile document in the context of the specified Activity. - * *

* The returned ResponseEntity contains the response headers and body. *

* * @param request The Consumer Builder for the put activity profile request - * * @return the ResponseEntity */ public Mono> putActivityProfile( @@ -1180,18 +1105,16 @@ public Mono> putActivityProfile( /** * Deletes the specified Profile document in the context of the specified Activity. - * *

* The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the delete activity profile request - * * @return the ResponseEntity */ public Mono> deleteActivityProfile(DeleteActivityProfileRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -1207,20 +1130,18 @@ public Mono> deleteActivityProfile(DeleteActivityProfileReq /** * Deletes the specified Profile document in the context of the specified Activity. - * *

* The returned ResponseEntity contains the response headers. *

* * @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); @@ -1232,19 +1153,17 @@ public Mono> deleteActivityProfile( * Fetches Profile ids of all Profile documents for an Activity. If "since" parameter is * specified, this is limited to entries that have been stored or updated since the specified * Timestamp (exclusive). - * *

* The returned ResponseEntity contains the response headers. *

* * @param request The parameters of the get activity profiles request - * * @return the ResponseEntity */ public Mono>> getActivityProfiles( GetActivityProfilesRequest request) { - Map queryParams = new HashMap<>(); + final Map queryParams = new HashMap<>(); return this.webClient @@ -1262,13 +1181,11 @@ public Mono>> getActivityProfiles( * Fetches Profile ids of all Profile documents for an Activity. If "since" parameter is * specified, this is limited to entries that have been stored or updated since the specified * Timestamp (exclusive). - * *

* The returned ResponseEntity contains the response headers. *

* * @param request The Consumer Builder for the get activity profiles request - * * @return the ResponseEntity */ public Mono>> getActivityProfiles( From cee5aaf739dac23eb8f7a97a6e0f15147cff2ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 13 Mar 2023 10:21:07 +0000 Subject: [PATCH 08/27] fixup --- .../learning/xapi/client/PostStatementsRequest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java index f9762f78..cb4623a6 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java @@ -5,6 +5,7 @@ package dev.learning.xapi.client; import dev.learning.xapi.model.Statement; +import java.util.Arrays; import java.util.List; import java.util.Map; import lombok.Builder; @@ -72,6 +73,17 @@ public Builder statements(List statements) { return this; } + /** + * Sets the statements. + * + * @param statements The statements of the PostStatementsRequest. + * @return This builder + * @see PostStatementsRequest#statements + */ + public Builder statements(Statement... statements) { + this.statements = Arrays.asList(statements); + return this; + } } } From 48dd75a762d83866a58bba291c56268b019adf40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 13 Mar 2023 10:33:06 +0000 Subject: [PATCH 09/27] sss --- .../dev/learning/xapi/client/MultipartHelper.java | 5 +++-- .../learning/xapi/client/PostStatementsRequest.java | 13 ------------- .../java/dev/learning/xapi/model/Attachment.java | 2 -- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java index 2c5efcee..dd062395 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java @@ -9,6 +9,7 @@ import dev.learning.xapi.model.Statement; import dev.learning.xapi.model.SubStatement; import java.util.List; +import java.util.stream.Collectors; import java.util.stream.Stream; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -139,8 +140,8 @@ private static String writeAttachments(Stream attachments) { final var body = new StringBuilder(); - // Write identical attachments only once - attachments.distinct().forEach(a -> { + // Write sha2-identical attachments only once + attachments.collect(Collectors.toMap(Attachment::getSha2, p -> p)).values().forEach(a -> { // Multipart Boundary body.append(BODY_SEPARATOR); diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java index cb4623a6..391c1129 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java @@ -5,7 +5,6 @@ package dev.learning.xapi.client; import dev.learning.xapi.model.Statement; -import java.util.Arrays; import java.util.List; import java.util.Map; import lombok.Builder; @@ -65,7 +64,6 @@ public Builder statements(List statements) { * @param statements The statements of the PostStatementsRequest. * * @return This builder - * * @see PostStatementsRequest#statements */ public Builder statements(List statements) { @@ -73,17 +71,6 @@ public Builder statements(List statements) { return this; } - /** - * Sets the statements. - * - * @param statements The statements of the PostStatementsRequest. - * @return This builder - * @see PostStatementsRequest#statements - */ - public Builder statements(Statement... statements) { - this.statements = Arrays.asList(statements); - return this; - } } } 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 c4a3b2dd..10112553 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 @@ -16,7 +16,6 @@ import java.security.NoSuchAlgorithmException; import java.util.Locale; import lombok.Builder; -import lombok.EqualsAndHashCode; import lombok.ToString; import lombok.Value; @@ -32,7 +31,6 @@ @Builder @JsonInclude(Include.NON_EMPTY) @ToString(exclude = "data") -@EqualsAndHashCode(of = "sha2") public class Attachment { /** From e44e10a16ae2012a9ea72d7e8e65aa82d10774b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 13 Mar 2023 12:42:48 +0000 Subject: [PATCH 10/27] fcs, tests, fixup --- .../learning/xapi/client/MultipartHelper.java | 32 +++++------ .../xapi/client/XapiClientMultipartTests.java | 2 - .../learning/xapi/client/XapiClientTests.java | 57 ------------------- .../dev/learning/xapi/model/Attachment.java | 10 +++- 4 files changed, 22 insertions(+), 79 deletions(-) diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java index dd062395..70832ad0 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java @@ -14,7 +14,6 @@ import lombok.AccessLevel; import lombok.NoArgsConstructor; import lombok.SneakyThrows; -import org.apache.commons.codec.binary.Base64; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.web.reactive.function.client.WebClient.RequestBodySpec; @@ -141,23 +140,20 @@ private static String writeAttachments(Stream attachments) { final var body = new StringBuilder(); // Write sha2-identical attachments only once - attachments.collect(Collectors.toMap(Attachment::getSha2, p -> p)).values().forEach(a -> { - // Multipart Boundary - body.append(BODY_SEPARATOR); - - // Multipart header - body.append(HttpHeaders.CONTENT_TYPE).append(':').append(a.getContentType()).append(CRLF); - body.append("Content-Transfer-Encoding:binary").append(CRLF); - body.append("X-Experience-API-Hash:").append(a.getSha2()).append(CRLF); - body.append(CRLF); - - // Multipart body - if (MediaType.TEXT_PLAIN_VALUE.equals(a.getContentType())) { - body.append(new String(a.getData())).append(CRLF); - } else { - body.append(Base64.decodeBase64(a.getData())).append(CRLF); - } - }); + attachments.collect(Collectors.toMap(Attachment::getSha2, v -> v, (k, v) -> v)).values() + .forEach(a -> { + // Multipart Boundary + body.append(BODY_SEPARATOR); + + // Multipart header + body.append(HttpHeaders.CONTENT_TYPE).append(':').append(a.getContentType()).append(CRLF); + body.append("Content-Transfer-Encoding:binary").append(CRLF); + body.append("X-Experience-API-Hash:").append(a.getSha2()).append(CRLF); + body.append(CRLF); + + // Multipart body + body.append(a.getData()).append(CRLF); + }); return body.toString(); } 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 index 63ed5895..132e035c 100644 --- a/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientMultipartTests.java +++ b/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientMultipartTests.java @@ -318,6 +318,4 @@ void whenPostingStatementsWithTimestampAndAttachmentThenNoExceptionIsThrown() } - - } 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 91fea8ac..d06ab622 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 @@ -6,7 +6,6 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.Is.is; import static org.hamcrest.core.IsInstanceOf.instanceOf; -import static org.hamcrest.core.StringStartsWith.startsWith; import dev.learning.xapi.model.About; import dev.learning.xapi.model.Activity; @@ -365,62 +364,6 @@ void whenPostingStatementThenContentTypeHeaderIsApplicationJson() throws Interru assertThat(recordedRequest.getHeader("content-type"), is("application/json")); } - @Test - void whenPostingStatementWithAttachmentThenBodyIsExpected() 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.data("Simple attachment") - .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 RecordedRequest 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 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.data("Simple attachment") - .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 RecordedRequest recordedRequest = mockWebServer.takeRequest(); - - // Then Content Type Header Is Multipart Mixed - assertThat(recordedRequest.getHeader("content-type"), startsWith("multipart/mixed")); - } - // Get Voided Statement @Test 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 10112553..3ca176e1 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 @@ -18,6 +18,7 @@ import lombok.Builder; import lombok.ToString; import lombok.Value; +import org.apache.commons.codec.digest.DigestUtils; /** * This class represents the xAPI Attachment object. @@ -87,8 +88,14 @@ public class Attachment { // **Warning** do not add fields that are not required by the xAPI specification. + /** + * The data of the attachment. + *

+ * This is the actual String representation of the attachment as it appears in the http message. + *

+ */ @JsonIgnore - private byte[] data; + private String data; /** * Builder for Attachment. @@ -222,7 +229,6 @@ private static String sha256Hex(byte[] data) { throw new IllegalArgumentException(e); } - } } } From e691e5cdc001c70ae8e6f358683602092619b9dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 13 Mar 2023 12:59:33 +0000 Subject: [PATCH 11/27] fcs --- .../main/java/dev/learning/xapi/client/MultipartHelper.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java index 70832ad0..caac399f 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java @@ -62,7 +62,7 @@ public static void addBody(RequestBodySpec requestSpec, Statement statement) { * Also sets the content-type to multipart/mixed if needed. * * @param requestSpec a {@link RequestBodySpec} object. - * @param statement list of {@link Statement}s to add. + * @param statements list of {@link Statement}s to add. */ public static void addBody(RequestBodySpec requestSpec, List statements) { @@ -71,7 +71,7 @@ public static void addBody(RequestBodySpec requestSpec, List statemen } - public static void addBody(RequestBodySpec requestSpec, Object statements, + private static void addBody(RequestBodySpec requestSpec, Object statements, Stream attachments) { final String attachmentsBody = writeAttachments(attachments); @@ -132,7 +132,7 @@ private static String createMultipartBody(Object statements, String attachments) } /* - * Writes distinct attachments. If there are no attachments in the stream then returns an empty + * Writes attachments to a String. If there are no attachments in the stream then returns an empty * String. */ private static String writeAttachments(Stream attachments) { From 7721b42f29aab4a15746447aebd6d3a41f89d04c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Tue, 14 Mar 2023 09:29:11 +0000 Subject: [PATCH 12/27] add signature handling --- pom.xml | 16 + xapi-model/pom.xml | 14 + .../dev/learning/xapi/model/Statement.java | 44 +++ .../learning/xapi/model/StatementTests.java | 334 ++++++++++++++++++ 4 files changed, 408 insertions(+) diff --git a/pom.xml b/pom.xml index fbbe3531..206c1dca 100644 --- a/pom.xml +++ b/pom.xml @@ -20,6 +20,7 @@ 3.2.1 10.6.0 1.0.0 + 0.11.5 Berry Cloud Ltd @@ -267,6 +268,21 @@ xapi-client 1.1.2-SNAPSHOT + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + diff --git a/xapi-model/pom.xml b/xapi-model/pom.xml index f5cfe6b4..ab4b4835 100644 --- a/xapi-model/pom.xml +++ b/xapi-model/pom.xml @@ -10,6 +10,20 @@ xAPI Model learning.dev xAPI Model + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + runtime + + + io.jsonwebtoken + jjwt-jackson + runtime + com.fasterxml.jackson.core jackson-databind 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 4d6d5e78..4ad9329c 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 @@ -16,9 +16,14 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import java.net.URI; +import java.security.PrivateKey; import java.time.Instant; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.UUID; import java.util.function.Consumer; import lombok.Builder; @@ -127,6 +132,45 @@ public static class Builder { // This static class extends the lombok builder. + /** + * Special build method for signing and building a {@link Statement}. + *

+ * An signature attachment is automatically added to the Statement's attachments. + *

+ * + * @param privateKey a {@link PrivateKey} for signing the {@link Statement}. + * @return an immutable, signed {@link Statement} object. + * @see + * Signed Statements + */ + public Statement sign(PrivateKey privateKey) { + Map claims = new HashMap(); + + // Put only the significant properties into the signature payload + // https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#statement-comparision-requirements + claims.put("actor", this.actor); + claims.put("verb", this.verb); + claims.put("object", this.object); + claims.put("result", this.result); + claims.put("context", this.context); + + String token = Jwts.builder().setClaims(claims).signWith(privateKey, SignatureAlgorithm.RS512) + .compact(); + + addAttachment(a -> a.usageType(URI.create("http://adlnet.gov/expapi/attachments/signature")) + + .addDisplay(Locale.ENGLISH, "JSW signature") + + .data(token) + + .length(token.length()) + + .contentType("application/octet-stream")); + + return build(); + } + /** * Consumer Builder for agent. * 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 db267d8d..f1d5838b 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 @@ -6,15 +6,22 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.startsWith; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Jwts; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validation; import jakarta.validation.Validator; import java.io.IOException; import java.net.URI; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; import java.time.Instant; import java.util.Collections; import java.util.LinkedHashMap; @@ -31,6 +38,7 @@ * @author Lukáš Sahula * @author Martin Myslik * @author Thomas Turrell-Croft + * @author István Rátkai (Selindek) */ @DisplayName("Statement tests") class StatementTests { @@ -680,4 +688,330 @@ void whenBuildingStatementWithTwoAttachmentsThenAttachmentsHasTwoEntries() { } + + @Test + void whenSigningStatementThenSignatureIsAddedAsAttachment() throws NoSuchAlgorithmException { + + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + // When Signing Statement + final LinkedHashMap extensions = new LinkedHashMap<>(); + extensions.put(URI.create("http://name"), "Kilby"); + + 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))) + + .version("1.0.0") + + .sign(keyPair.getPrivate()); + + // Then Signature is Added As Attachment + assertThat(statement.getAttachments(), hasSize(1)); + + } + + @Test + void whenSigningStatementWithAttachmentThenSignatureIsAddedAsAttachment() throws NoSuchAlgorithmException { + + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + // When Signing Statement + 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) + + .version("1.0.0") + + .sign(keyPair.getPrivate()); + + // Then Signature is Added As Attachment + assertThat(statement.getAttachments(), hasSize(2)); + + } + + @Test + void whenSigningStatementThenSignatureIsExpected() throws NoSuchAlgorithmException { + + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + // When Signing Statement + final LinkedHashMap extensions = new LinkedHashMap<>(); + extensions.put(URI.create("http://name"), "Kilby"); + + 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))) + + .version("1.0.0") + + .sign(keyPair.getPrivate()); + + // Then Signature is Expected + assertThat(statement.getAttachments().get(0).getData(), startsWith("eyJhbGciOiJSUzUxMiJ9.eyJhY3RvciI6eyJuYW1lIjoiQSBOIE90aGVyIn0sInJlc3VsdCI6eyJzdWNjZXNzIjp0cnVlLCJjb21wbGV0aW9uIjp0cnVlLCJyZXNwb25zZSI6IlJlc3BvbnNlIiwiZHVyYXRpb24iOiJQMUQifSwidmVyYiI6eyJpZCI6Imh0dHA6Ly9leGFtcGxlLmNvbS94YXBpL3ZlcmJzI3NlbnQtYS1zdGF0ZW1lbnQiLCJkaXNwbGF5Ijp7ImVuX1VTIjoiYXR0ZW5kZWQifX0sImNvbnRleHQiOnsicmVnaXN0cmF0aW9uIjoiZWM1MzEyNzctYjU3Yi00YzE1LThkOTEtZDI5MmM1YjJiOGY3IiwiaW5zdHJ1Y3RvciI6eyJvYmplY3RUeXBlIjoiQWdlbnQiLCJuYW1lIjoiQSBOIE90aGVyIiwiYWNjb3VudCI6eyJob21lUGFnZSI6Imh0dHBzOi8vZXhhbXBsZS5jb20iLCJuYW1lIjoiMTM5MzY3NDkifX0sInRlYW0iOnsib2JqZWN0VHlwZSI6Ikdyb3VwIiwibmFtZSI6IlRlYW0iLCJtYm94IjoibWFpbHRvOnRlYW1AZXhhbXBsZS5jb20ifSwicGxhdGZvcm0iOiJFeGFtcGxlIHZpcnR1YWwgbWVldGluZyBzb2Z0d2FyZSIsImxhbmd1YWdlIjoiZW4iLCJzdGF0ZW1lbnQiOnsib2JqZWN0VHlwZSI6IlN0YXRlbWVudFJlZiIsImlkIjoiNjY5MGU2YzktM2VmMC00ZWQzLThiMzctN2YzOTY0NzMwYmVlIn19LCJvYmplY3QiOnsiaWQiOiJodHRwOi8vd3d3LmV4YW1wbGUuY29tL21lZXRpbmdzL29jY3VyYW5jZXMvMzQ1MzQiLCJkZWZpbml0aW9uIjp7Im5hbWUiOnsiZW5fR0IiOiJBIHNpbXBsZSBFeHBlcmllbmNlIEFQSSBzdGF0ZW1lbnQuIE5vdGUgdGhhdCB0aGUgTFJTIGRvZXMgbm90IG5lZWQgdG8gaGF2ZSBhbnkgcHJpb3IgaW5mb3JtYXRpb24gYWJvdXQgdGhlIEFjdG9yIChsZWFybmVyKSwgdGhlIHZlcmIsIG9yIHRoZSBBY3Rpdml0eS9vYmplY3QuIn0sImRlc2NyaXB0aW9uIjp7ImVuX0dCIjoiQSBzaW1wbGUgRXhwZXJpZW5jZSBBUEkgc3RhdGVtZW50LiBOb3RlIHRoYXQgdGhlIExSUyBkb2VzIG5vdCBuZWVkIHRvIGhhdmUgYW55IHByaW9yIGluZm9ybWF0aW9uIGFib3V0IHRoZSBBY3RvciAobGVhcm5lciksIHRoZSB2ZXJiLCBvciB0aGUgQWN0aXZpdHkvb2JqZWN0LiJ9LCJ0eXBlIjoiaHR0cDovL2FkbG5ldC5nb3YvZXhwYXBpL2FjdGl2aXRpZXMvbWVldGluZyIsIm1vcmVJbmZvIjoiaHR0cDovL3ZpcnR1YWxtZWV0aW5nLmV4YW1wbGUuY29tLzM0NTI1NiIsImV4dGVuc2lvbnMiOnsiaHR0cDovL25hbWUiOiJLaWxieSJ9fX19.")); + + } + + @Test + void whenSigningStatementThenSignatureIsValid() throws NoSuchAlgorithmException, JsonMappingException, JsonProcessingException { + + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + // When Signing Statement + final LinkedHashMap extensions = new LinkedHashMap<>(); + extensions.put(URI.create("http://name"), "Kilby"); + + 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))) + + .version("1.0.0") + + .sign(keyPair.getPrivate()); + + // Then Signature is Valid + var body = Jwts.parserBuilder().setSigningKey(keyPair.getPublic()).build() + .parseClaimsJws(statement.getAttachments().get(0).getData()).getBody(); + + var bodyStatement = objectMapper.readValue(objectMapper.writeValueAsString(body), Statement.class); + + assertThat(bodyStatement, is(statement)); + + } } From f6bec6071072773d3dd45a6e66160e4b43262b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Tue, 14 Mar 2023 09:42:05 +0000 Subject: [PATCH 13/27] fsi --- .../src/main/java/dev/learning/xapi/model/Statement.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 4ad9329c..82f24ecf 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 @@ -23,7 +23,6 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.UUID; import java.util.function.Consumer; import lombok.Builder; @@ -145,7 +144,7 @@ public static class Builder { * Signed Statements */ public Statement sign(PrivateKey privateKey) { - Map claims = new HashMap(); + var claims = new HashMap(); // Put only the significant properties into the signature payload // https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#statement-comparision-requirements From ccf2f4611c8e3fecc4ee685548e8991d26acc1fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Fri, 10 Mar 2023 10:57:13 +0000 Subject: [PATCH 14/27] add support for attachments --- .../java/dev/learning/xapi/model/Statement.java | 5 ++++- .../dev/learning/xapi/model/StatementTests.java | 1 + .../test/resources/sub_statement/sub_statement.json | 13 +++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) 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 82f24ecf..a1ffa1f4 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 @@ -13,6 +13,8 @@ import dev.learning.xapi.model.validation.constraints.ValidStatementRevision; import dev.learning.xapi.model.validation.constraints.ValidStatementVerb; import dev.learning.xapi.model.validation.constraints.Variant; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; @@ -23,6 +25,7 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.UUID; import java.util.function.Consumer; import lombok.Builder; @@ -144,7 +147,7 @@ public static class Builder { * Signed Statements */ public Statement sign(PrivateKey privateKey) { - var claims = new HashMap(); + Map claims = new HashMap(); // Put only the significant properties into the signature payload // https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#statement-comparision-requirements 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 f1d5838b..791a36e2 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 @@ -12,6 +12,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.Jwts; import jakarta.validation.ConstraintViolation; 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 234e4dda..3f37f498 100644 --- a/xapi-model/src/test/resources/sub_statement/sub_statement.json +++ b/xapi-model/src/test/resources/sub_statement/sub_statement.json @@ -79,6 +79,19 @@ "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" + }, { "usageType" : "http://example.com", "display" : { From 4f9143bbc0dcad614e0fdb3538a22983752a36a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Thu, 30 Mar 2023 17:46:52 +0100 Subject: [PATCH 15/27] Merge branch 'get_attachments' of https://github.com/BerryCloud/xapi-java.git into get_attachments --- .../dev/learning/xapi/model/Attachment.java | 7 +- .../learning/xapi/model/StatementTests.java | 79 ++++++++++--------- .../sub_statement/sub_statement.json | 13 --- 3 files changed, 45 insertions(+), 54 deletions(-) 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 3ca176e1..dca7f623 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 @@ -18,7 +18,6 @@ import lombok.Builder; import lombok.ToString; import lombok.Value; -import org.apache.commons.codec.digest.DigestUtils; /** * This class represents the xAPI Attachment object. @@ -91,12 +90,12 @@ public class Attachment { /** * The data of the attachment. *

- * This is the actual String representation of the attachment as it appears in the http message. + * This is the actual String representation of the attachment as it appears in the http message. *

*/ @JsonIgnore private String data; - + /** * Builder for Attachment. */ @@ -229,6 +228,8 @@ private static String sha256Hex(byte[] data) { throw new IllegalArgumentException(e); } + } + } } 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 791a36e2..06dcaebd 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 @@ -6,13 +6,12 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.startsWith; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.Jwts; import jakarta.validation.ConstraintViolation; @@ -693,15 +692,15 @@ void whenBuildingStatementWithTwoAttachmentsThenAttachmentsHasTwoEntries() { @Test void whenSigningStatementThenSignatureIsAddedAsAttachment() throws NoSuchAlgorithmException { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + final var keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); - KeyPair keyPair = keyPairGenerator.generateKeyPair(); - + final var keyPair = keyPairGenerator.generateKeyPair(); + // When Signing Statement - final LinkedHashMap extensions = new LinkedHashMap<>(); + final var extensions = new LinkedHashMap(); extensions.put(URI.create("http://name"), "Kilby"); - final Account account = Account.builder() + final var account = Account.builder() .homePage(URI.create("https://example.com")) @@ -710,7 +709,7 @@ void whenSigningStatementThenSignatureIsAddedAsAttachment() throws NoSuchAlgorit .build(); - final Statement statement = Statement.builder() + final var statement = Statement.builder() .id(UUID.fromString("4b9175ba-367d-4b93-990b-34d4180039f1")) @@ -760,24 +759,25 @@ void whenSigningStatementThenSignatureIsAddedAsAttachment() throws NoSuchAlgorit .version("1.0.0") .sign(keyPair.getPrivate()); - + // Then Signature is Added As Attachment assertThat(statement.getAttachments(), hasSize(1)); } - + @Test - void whenSigningStatementWithAttachmentThenSignatureIsAddedAsAttachment() throws NoSuchAlgorithmException { + void whenSigningStatementWithAttachmentThenSignatureIsAddedAsAttachment() + throws NoSuchAlgorithmException { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + final var keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); - KeyPair keyPair = keyPairGenerator.generateKeyPair(); - + final var keyPair = keyPairGenerator.generateKeyPair(); + // When Signing Statement - final LinkedHashMap extensions = new LinkedHashMap<>(); + final var extensions = new LinkedHashMap(); extensions.put(URI.create("http://name"), "Kilby"); - final Attachment attachment = Attachment.builder().usageType(URI.create("http://example.com")) + final var attachment = Attachment.builder().usageType(URI.create("http://example.com")) .fileUrl(URI.create("http://example.com")) .addDisplay(Locale.ENGLISH, "value") @@ -792,7 +792,7 @@ void whenSigningStatementWithAttachmentThenSignatureIsAddedAsAttachment() throws .build(); - final Account account = Account.builder() + final var account = Account.builder() .homePage(URI.create("https://example.com")) @@ -801,7 +801,7 @@ void whenSigningStatementWithAttachmentThenSignatureIsAddedAsAttachment() throws .build(); - final Statement statement = Statement.builder() + final var statement = Statement.builder() .id(UUID.fromString("4b9175ba-367d-4b93-990b-34d4180039f1")) @@ -853,7 +853,7 @@ void whenSigningStatementWithAttachmentThenSignatureIsAddedAsAttachment() throws .version("1.0.0") .sign(keyPair.getPrivate()); - + // Then Signature is Added As Attachment assertThat(statement.getAttachments(), hasSize(2)); @@ -862,15 +862,15 @@ void whenSigningStatementWithAttachmentThenSignatureIsAddedAsAttachment() throws @Test void whenSigningStatementThenSignatureIsExpected() throws NoSuchAlgorithmException { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + final var keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); - KeyPair keyPair = keyPairGenerator.generateKeyPair(); - + final var keyPair = keyPairGenerator.generateKeyPair(); + // When Signing Statement - final LinkedHashMap extensions = new LinkedHashMap<>(); + final var extensions = new LinkedHashMap(); extensions.put(URI.create("http://name"), "Kilby"); - final Account account = Account.builder() + final var account = Account.builder() .homePage(URI.create("https://example.com")) @@ -879,7 +879,7 @@ void whenSigningStatementThenSignatureIsExpected() throws NoSuchAlgorithmExcepti .build(); - final Statement statement = Statement.builder() + final var statement = Statement.builder() .id(UUID.fromString("4b9175ba-367d-4b93-990b-34d4180039f1")) @@ -929,24 +929,26 @@ void whenSigningStatementThenSignatureIsExpected() throws NoSuchAlgorithmExcepti .version("1.0.0") .sign(keyPair.getPrivate()); - + // Then Signature is Expected - assertThat(statement.getAttachments().get(0).getData(), startsWith("eyJhbGciOiJSUzUxMiJ9.eyJhY3RvciI6eyJuYW1lIjoiQSBOIE90aGVyIn0sInJlc3VsdCI6eyJzdWNjZXNzIjp0cnVlLCJjb21wbGV0aW9uIjp0cnVlLCJyZXNwb25zZSI6IlJlc3BvbnNlIiwiZHVyYXRpb24iOiJQMUQifSwidmVyYiI6eyJpZCI6Imh0dHA6Ly9leGFtcGxlLmNvbS94YXBpL3ZlcmJzI3NlbnQtYS1zdGF0ZW1lbnQiLCJkaXNwbGF5Ijp7ImVuX1VTIjoiYXR0ZW5kZWQifX0sImNvbnRleHQiOnsicmVnaXN0cmF0aW9uIjoiZWM1MzEyNzctYjU3Yi00YzE1LThkOTEtZDI5MmM1YjJiOGY3IiwiaW5zdHJ1Y3RvciI6eyJvYmplY3RUeXBlIjoiQWdlbnQiLCJuYW1lIjoiQSBOIE90aGVyIiwiYWNjb3VudCI6eyJob21lUGFnZSI6Imh0dHBzOi8vZXhhbXBsZS5jb20iLCJuYW1lIjoiMTM5MzY3NDkifX0sInRlYW0iOnsib2JqZWN0VHlwZSI6Ikdyb3VwIiwibmFtZSI6IlRlYW0iLCJtYm94IjoibWFpbHRvOnRlYW1AZXhhbXBsZS5jb20ifSwicGxhdGZvcm0iOiJFeGFtcGxlIHZpcnR1YWwgbWVldGluZyBzb2Z0d2FyZSIsImxhbmd1YWdlIjoiZW4iLCJzdGF0ZW1lbnQiOnsib2JqZWN0VHlwZSI6IlN0YXRlbWVudFJlZiIsImlkIjoiNjY5MGU2YzktM2VmMC00ZWQzLThiMzctN2YzOTY0NzMwYmVlIn19LCJvYmplY3QiOnsiaWQiOiJodHRwOi8vd3d3LmV4YW1wbGUuY29tL21lZXRpbmdzL29jY3VyYW5jZXMvMzQ1MzQiLCJkZWZpbml0aW9uIjp7Im5hbWUiOnsiZW5fR0IiOiJBIHNpbXBsZSBFeHBlcmllbmNlIEFQSSBzdGF0ZW1lbnQuIE5vdGUgdGhhdCB0aGUgTFJTIGRvZXMgbm90IG5lZWQgdG8gaGF2ZSBhbnkgcHJpb3IgaW5mb3JtYXRpb24gYWJvdXQgdGhlIEFjdG9yIChsZWFybmVyKSwgdGhlIHZlcmIsIG9yIHRoZSBBY3Rpdml0eS9vYmplY3QuIn0sImRlc2NyaXB0aW9uIjp7ImVuX0dCIjoiQSBzaW1wbGUgRXhwZXJpZW5jZSBBUEkgc3RhdGVtZW50LiBOb3RlIHRoYXQgdGhlIExSUyBkb2VzIG5vdCBuZWVkIHRvIGhhdmUgYW55IHByaW9yIGluZm9ybWF0aW9uIGFib3V0IHRoZSBBY3RvciAobGVhcm5lciksIHRoZSB2ZXJiLCBvciB0aGUgQWN0aXZpdHkvb2JqZWN0LiJ9LCJ0eXBlIjoiaHR0cDovL2FkbG5ldC5nb3YvZXhwYXBpL2FjdGl2aXRpZXMvbWVldGluZyIsIm1vcmVJbmZvIjoiaHR0cDovL3ZpcnR1YWxtZWV0aW5nLmV4YW1wbGUuY29tLzM0NTI1NiIsImV4dGVuc2lvbnMiOnsiaHR0cDovL25hbWUiOiJLaWxieSJ9fX19.")); + assertThat(statement.getAttachments().get(0).getData(), startsWith( + "eyJhbGciOiJSUzUxMiJ9.eyJhY3RvciI6eyJuYW1lIjoiQSBOIE90aGVyIn0sInJlc3VsdCI6eyJzdWNjZXNzIjp0cnVlLCJjb21wbGV0aW9uIjp0cnVlLCJyZXNwb25zZSI6IlJlc3BvbnNlIiwiZHVyYXRpb24iOiJQMUQifSwidmVyYiI6eyJpZCI6Imh0dHA6Ly9leGFtcGxlLmNvbS94YXBpL3ZlcmJzI3NlbnQtYS1zdGF0ZW1lbnQiLCJkaXNwbGF5Ijp7ImVuLVVTIjoiYXR0ZW5kZWQifX0sImNvbnRleHQiOnsicmVnaXN0cmF0aW9uIjoiZWM1MzEyNzctYjU3Yi00YzE1LThkOTEtZDI5MmM1YjJiOGY3IiwiaW5zdHJ1Y3RvciI6eyJvYmplY3RUeXBlIjoiQWdlbnQiLCJuYW1lIjoiQSBOIE90aGVyIiwiYWNjb3VudCI6eyJob21lUGFnZSI6Imh0dHBzOi8vZXhhbXBsZS5jb20iLCJuYW1lIjoiMTM5MzY3NDkifX0sInRlYW0iOnsib2JqZWN0VHlwZSI6Ikdyb3VwIiwibmFtZSI6IlRlYW0iLCJtYm94IjoibWFpbHRvOnRlYW1AZXhhbXBsZS5jb20ifSwicGxhdGZvcm0iOiJFeGFtcGxlIHZpcnR1YWwgbWVldGluZyBzb2Z0d2FyZSIsImxhbmd1YWdlIjoiZW4iLCJzdGF0ZW1lbnQiOnsib2JqZWN0VHlwZSI6IlN0YXRlbWVudFJlZiIsImlkIjoiNjY5MGU2YzktM2VmMC00ZWQzLThiMzctN2YzOTY0NzMwYmVlIn19LCJvYmplY3QiOnsiaWQiOiJodHRwOi8vd3d3LmV4YW1wbGUuY29tL21lZXRpbmdzL29jY3VyYW5jZXMvMzQ1MzQiLCJkZWZpbml0aW9uIjp7Im5hbWUiOnsiZW4tR0IiOiJBIHNpbXBsZSBFeHBlcmllbmNlIEFQSSBzdGF0ZW1lbnQuIE5vdGUgdGhhdCB0aGUgTFJTIGRvZXMgbm90IG5lZWQgdG8gaGF2ZSBhbnkgcHJpb3IgaW5mb3JtYXRpb24gYWJvdXQgdGhlIEFjdG9yIChsZWFybmVyKSwgdGhlIHZlcmIsIG9yIHRoZSBBY3Rpdml0eS9vYmplY3QuIn0sImRlc2NyaXB0aW9uIjp7ImVuLUdCIjoiQSBzaW1wbGUgRXhwZXJpZW5jZSBBUEkgc3RhdGVtZW50LiBOb3RlIHRoYXQgdGhlIExSUyBkb2VzIG5vdCBuZWVkIHRvIGhhdmUgYW55IHByaW9yIGluZm9ybWF0aW9uIGFib3V0IHRoZSBBY3RvciAobGVhcm5lciksIHRoZSB2ZXJiLCBvciB0aGUgQWN0aXZpdHkvb2JqZWN0LiJ9LCJ0eXBlIjoiaHR0cDovL2FkbG5ldC5nb3YvZXhwYXBpL2FjdGl2aXRpZXMvbWVldGluZyIsIm1vcmVJbmZvIjoiaHR0cDovL3ZpcnR1YWxtZWV0aW5nLmV4YW1wbGUuY29tLzM0NTI1NiIsImV4dGVuc2lvbnMiOnsiaHR0cDovL25hbWUiOiJLaWxieSJ9fX19.")); } - + @Test - void whenSigningStatementThenSignatureIsValid() throws NoSuchAlgorithmException, JsonMappingException, JsonProcessingException { + void whenSigningStatementThenSignatureIsValid() + throws NoSuchAlgorithmException, JsonMappingException, JsonProcessingException { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + final var keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); - KeyPair keyPair = keyPairGenerator.generateKeyPair(); - + final var keyPair = keyPairGenerator.generateKeyPair(); + // When Signing Statement - final LinkedHashMap extensions = new LinkedHashMap<>(); + final var extensions = new LinkedHashMap(); extensions.put(URI.create("http://name"), "Kilby"); - final Account account = Account.builder() + final var account = Account.builder() .homePage(URI.create("https://example.com")) @@ -955,7 +957,7 @@ void whenSigningStatementThenSignatureIsValid() throws NoSuchAlgorithmException, .build(); - final Statement statement = Statement.builder() + final var statement = Statement.builder() .id(UUID.fromString("4b9175ba-367d-4b93-990b-34d4180039f1")) @@ -1005,12 +1007,13 @@ void whenSigningStatementThenSignatureIsValid() throws NoSuchAlgorithmException, .version("1.0.0") .sign(keyPair.getPrivate()); - + // Then Signature is Valid - var body = Jwts.parserBuilder().setSigningKey(keyPair.getPublic()).build() + final var body = Jwts.parserBuilder().setSigningKey(keyPair.getPublic()).build() .parseClaimsJws(statement.getAttachments().get(0).getData()).getBody(); - var bodyStatement = objectMapper.readValue(objectMapper.writeValueAsString(body), Statement.class); + final var bodyStatement = + objectMapper.readValue(objectMapper.writeValueAsString(body), Statement.class); assertThat(bodyStatement, is(statement)); 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 3f37f498..234e4dda 100644 --- a/xapi-model/src/test/resources/sub_statement/sub_statement.json +++ b/xapi-model/src/test/resources/sub_statement/sub_statement.json @@ -79,19 +79,6 @@ "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" - }, { "usageType" : "http://example.com", "display" : { From fad29ebed71c521bc8352f45016ee6d253055603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Fri, 31 Mar 2023 09:19:43 +0100 Subject: [PATCH 16/27] fixup --- .../xapi/client/PostStatementsRequest.java | 8 ++- .../dev/learning/xapi/client/XapiClient.java | 55 +++++++++++++++---- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java index 391c1129..8e9751d9 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java @@ -5,6 +5,7 @@ package dev.learning.xapi.client; import dev.learning.xapi.model.Statement; +import java.util.Arrays; import java.util.List; import java.util.Map; import lombok.Builder; @@ -18,6 +19,7 @@ * @see POST * Statements + * * @author Thomas Turrell-Croft */ @Builder @@ -49,6 +51,7 @@ public static class Builder { * Sets the statements. * * @param statements The statements of the PostStatementsRequest. + * * @return This builder * * @see PostStatementsRequest#statements @@ -64,10 +67,11 @@ public Builder statements(List statements) { * @param statements The statements of the PostStatementsRequest. * * @return This builder + * * @see PostStatementsRequest#statements */ - public Builder statements(List statements) { - this.statements = statements; + public Builder statements(Statement... statements) { + this.statements = Arrays.asList(statements); return this; } 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 1a4a44d6..c15626d9 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 @@ -25,6 +25,7 @@ * * @author Thomas Turrell-Croft * @author István Rátkai (Selindek) + * * @see xAPI * communication resources @@ -39,19 +40,11 @@ public class XapiClient { private static final ParameterizedTypeReference> LIST_STRING_TYPE = new ParameterizedTypeReference<>() {}; - private static final ParameterizedTypeReference< - List> LIST_UUID_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) { this.webClient = builder @@ -268,6 +261,7 @@ public Mono> getStatements() { *

* * @param request The parameters of the get statements request + * * @return the ResponseEntity */ public Mono> getStatements(GetStatementsRequest request) { @@ -294,6 +288,7 @@ public Mono> getStatements(GetStatementsRequest *

* * @param request The Consumer Builder for the get statements request + * * @return the ResponseEntity */ public Mono> getStatements( @@ -315,6 +310,7 @@ public Mono> getStatements( *

* * @param request The parameters of the get more statements request + * * @return the ResponseEntity */ public Mono> getMoreStatements(GetMoreStatementsRequest request) { @@ -341,6 +337,7 @@ public Mono> getMoreStatements(GetMoreStatements *

* * @param request The Consumer Builder for the get more statements request + * * @return the ResponseEntity */ public Mono> getMoreStatements( @@ -364,6 +361,7 @@ public Mono> getMoreStatements( *

* * @param request The parameters of the get state request + * * @return the ResponseEntity */ public Mono> getState(GetStateRequest request, Class bodyType) { @@ -390,6 +388,7 @@ 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, @@ -411,6 +410,7 @@ public Mono> getState(Consumer * * @param request The parameters of the post state request + * * @return the ResponseEntity */ public Mono> postState(PostStateRequest request) { @@ -441,6 +441,7 @@ public Mono> postState(PostStateRequest request) { *

* * @param request The Consumer Builder for the post state request + * * @return the ResponseEntity */ public Mono> postState(Consumer> request) { @@ -461,6 +462,7 @@ public Mono> postState(Consumer * * @param request The parameters of the put state request + * * @return the ResponseEntity */ public Mono> putState(PutStateRequest request) { @@ -491,6 +493,7 @@ public Mono> putState(PutStateRequest request) { *

* * @param request The Consumer Builder for the put state request + * * @return the ResponseEntity */ public Mono> putState(Consumer> request) { @@ -511,6 +514,7 @@ public Mono> putState(Consumer * * @param request The parameters of the delete state request + * * @return the ResponseEntity */ public Mono> deleteState(DeleteStateRequest request) { @@ -537,6 +541,7 @@ public Mono> deleteState(DeleteStateRequest request) { *

* * @param request The Consumer Builder for the delete state request + * * @return the ResponseEntity */ public Mono> deleteState( @@ -555,6 +560,7 @@ public Mono> deleteState( * parameters. * * @param request The parameters of the get states request + * * @return the ResponseEntity */ public Mono>> getStates(GetStatesRequest request) { @@ -581,6 +587,7 @@ public Mono>> getStates(GetStatesRequest request) { *

* * @param request The Consumer Builder for the get states request + * * @return the ResponseEntity */ public Mono>> getStates( @@ -601,6 +608,7 @@ public Mono>> getStates( *

* * @param request The parameters of the delete states request + * * @return the ResponseEntity */ public Mono> deleteStates(DeleteStatesRequest request) { @@ -626,6 +634,7 @@ public Mono> deleteStates(DeleteStatesRequest request) { *

* * @param request The Consumer Builder for the delete states request + * * @return the ResponseEntity */ public Mono> deleteStates( @@ -647,6 +656,7 @@ 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) { @@ -671,6 +681,7 @@ 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) { @@ -689,6 +700,7 @@ 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) { @@ -711,6 +723,7 @@ 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) { @@ -732,6 +745,7 @@ public Mono> getActivity(Consumer * * @param request The parameters of the get agent profile request + * * @return the ResponseEntity */ public Mono> getAgentProfile(GetAgentProfileRequest request, @@ -758,6 +772,7 @@ public Mono> getAgentProfile(GetAgentProfileRequest reques *

* * @param request The Consumer Builder for the get agent profile request + * * @return the ResponseEntity */ public Mono> getAgentProfile( @@ -778,6 +793,7 @@ public Mono> getAgentProfile( *

* * @param request The parameters of the delete agent profile request + * * @return the ResponseEntity */ public Mono> deleteAgentProfile(DeleteAgentProfileRequest request) { @@ -803,6 +819,7 @@ public Mono> deleteAgentProfile(DeleteAgentProfileRequest r *

* * @param request The Consumer Builder for the delete agent profile request + * * @return the ResponseEntity */ public Mono> deleteAgentProfile( @@ -823,6 +840,7 @@ public Mono> deleteAgentProfile( *

* * @param request The parameters of the put agent profile request + * * @return the ResponseEntity */ public Mono> putAgentProfile(PutAgentProfileRequest request) { @@ -852,6 +870,7 @@ public Mono> putAgentProfile(PutAgentProfileRequest request *

* * @param request The Consumer Builder for the put agent profile request + * * @return the ResponseEntity */ public Mono> putAgentProfile( @@ -872,6 +891,7 @@ public Mono> putAgentProfile( *

* * @param request The parameters of the post agent profile request + * * @return the ResponseEntity */ public Mono> postAgentProfile(PostAgentProfileRequest request) { @@ -901,6 +921,7 @@ public Mono> postAgentProfile(PostAgentProfileRequest reque *

* * @param request The Consumer Builder for the post agent profile request + * * @return the ResponseEntity */ public Mono> postAgentProfile( @@ -920,6 +941,7 @@ public Mono> postAgentProfile( * (exclusive). * * @param request The parameters of the get agent profiles request + * * @return the ResponseEntity */ public Mono>> getAgentProfiles(GetAgentProfilesRequest request) { @@ -944,6 +966,7 @@ public Mono>> getAgentProfiles(GetAgentProfilesReque * (exclusive). * * @param request The Consumer Builder for the get agent profiles request + * * @return the ResponseEntity */ public Mono>> getAgentProfiles( @@ -966,6 +989,7 @@ public Mono>> getAgentProfiles( *

* * @param request The parameters of the get activity profile request + * * @return the ResponseEntity */ public Mono> getActivityProfile(GetActivityProfileRequest request, @@ -992,6 +1016,7 @@ public Mono> getActivityProfile(GetActivityProfileRequest *

* * @param request The Consumer Builder for the get activity profile request + * * @return the ResponseEntity */ public Mono> getActivityProfile( @@ -1012,6 +1037,7 @@ public Mono> getActivityProfile( *

* * @param request The parameters of the post activity profile request + * * @return the ResponseEntity */ public Mono> postActivityProfile(PostActivityProfileRequest request) { @@ -1041,6 +1067,7 @@ public Mono> postActivityProfile(PostActivityProfileRequest *

* * @param request The Consumer Builder for the post activity profile request + * * @return the ResponseEntity */ public Mono> postActivityProfile( @@ -1061,6 +1088,7 @@ public Mono> postActivityProfile( *

* * @param request The parameters of the put activity profile request + * * @return the ResponseEntity */ public Mono> putActivityProfile(PutActivityProfileRequest request) { @@ -1090,6 +1118,7 @@ public Mono> putActivityProfile(PutActivityProfileRequest r *

* * @param request The Consumer Builder for the put activity profile request + * * @return the ResponseEntity */ public Mono> putActivityProfile( @@ -1110,6 +1139,7 @@ public Mono> putActivityProfile( *

* * @param request The parameters of the delete activity profile request + * * @return the ResponseEntity */ public Mono> deleteActivityProfile(DeleteActivityProfileRequest request) { @@ -1135,13 +1165,14 @@ 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); @@ -1158,6 +1189,7 @@ public Mono> deleteActivityProfile( *

* * @param request The parameters of the get activity profiles request + * * @return the ResponseEntity */ public Mono>> getActivityProfiles( @@ -1186,6 +1218,7 @@ public Mono>> getActivityProfiles( *

* * @param request The Consumer Builder for the get activity profiles request + * * @return the ResponseEntity */ public Mono>> getActivityProfiles( From 5a24762fe963201482675bab9bf7cea63f24f453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Fri, 31 Mar 2023 09:46:44 +0100 Subject: [PATCH 17/27] fsi --- xapi-model/src/main/java/dev/learning/xapi/model/Statement.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a1ffa1f4..1f997feb 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 @@ -147,7 +147,7 @@ public static class Builder { * Signed Statements */ public Statement sign(PrivateKey privateKey) { - Map claims = new HashMap(); + Map claims = new HashMap<>(); // Put only the significant properties into the signature payload // https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#statement-comparision-requirements From 7fc3a9d4d955c81c298ad6011daea2c2c30fb779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Fri, 31 Mar 2023 09:50:55 +0100 Subject: [PATCH 18/27] fix merge issues --- .../learning/xapi/client/MultipartHelper.java | 161 ------------------ .../dev/learning/xapi/client/XapiClient.java | 16 +- .../dev/learning/xapi/model/Attachment.java | 14 +- 3 files changed, 10 insertions(+), 181 deletions(-) delete mode 100644 xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java deleted file mode 100644 index caac399f..00000000 --- a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartHelper.java +++ /dev/null @@ -1,161 +0,0 @@ -/* - * 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.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; -import lombok.SneakyThrows; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.web.reactive.function.client.WebClient.RequestBodySpec; - -/** - * Helper methods for creating multipart message from statements. - * - * @author István Rátkai (Selindek) - */ -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public final class MultipartHelper { - - 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; - - public static final MediaType MULTIPART_MEDIATYPE = MediaType.valueOf(MULTIPART_CONTENT_TYPE); - - private static final ObjectMapper objectMapper = new 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 static 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 static void addBody(RequestBodySpec requestSpec, List statements) { - - addBody(requestSpec, statements, - statements.stream().flatMap(MultipartHelper::getRealAttachments)); - - } - - private static void addBody(RequestBodySpec requestSpec, Object statements, - Stream attachments) { - - final String attachmentsBody = writeAttachments(attachments); - - if (attachmentsBody.isEmpty()) { - // 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 static 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.getData() != null); - } - - @SneakyThrows - private static String createMultipartBody(Object statements, String attachments) { - final var body = new StringBuilder(); - // Multipart Boundary - body.append(BODY_SEPARATOR); - - // Header of first part - body.append(HttpHeaders.CONTENT_TYPE).append(':').append(MediaType.APPLICATION_JSON_VALUE) - .append(CRLF); - body.append(CRLF); - - // Body of first part - body.append(objectMapper.writeValueAsString(statements)).append(CRLF); - - // Body of attachments - body.append(attachments); - - // Footer - body.append(BODY_FOOTER); - - return body.toString(); - } - - /* - * Writes attachments to a String. If there are no attachments in the stream then returns an empty - * String. - */ - private static String writeAttachments(Stream attachments) { - - final var body = new StringBuilder(); - - // Write sha2-identical attachments only once - attachments.collect(Collectors.toMap(Attachment::getSha2, v -> v, (k, v) -> v)).values() - .forEach(a -> { - // Multipart Boundary - body.append(BODY_SEPARATOR); - - // Multipart header - body.append(HttpHeaders.CONTENT_TYPE).append(':').append(a.getContentType()).append(CRLF); - body.append("Content-Transfer-Encoding:binary").append(CRLF); - body.append("X-Experience-API-Hash:").append(a.getSha2()).append(CRLF); - body.append(CRLF); - - // Multipart body - body.append(a.getData()).append(CRLF); - }); - - return body.toString(); - } - -} 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 c15626d9..1c122478 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 @@ -115,15 +115,15 @@ public Mono> postStatement(PostStatementRequest request) { final Map queryParams = new HashMap<>(); - final var requestSpec = this.webClient + return this.webClient .method(request.getMethod()) - .uri(u -> request.url(u, queryParams).build(queryParams)); + .uri(u -> request.url(u, queryParams).build(queryParams)) - MultipartHelper.addBody(requestSpec, request.getStatement()); + .bodyValue(request.getStatement()) - return requestSpec.retrieve() + .retrieve() .toEntity(LIST_UUID_TYPE) @@ -162,15 +162,15 @@ public Mono>> postStatements(PostStatementsRequest req final Map queryParams = new HashMap<>(); - final var requestSpec = this.webClient + return this.webClient .method(request.getMethod()) - .uri(u -> request.url(u, queryParams).build(queryParams)); + .uri(u -> request.url(u, queryParams).build(queryParams)) - MultipartHelper.addBody(requestSpec, request.getStatements()); + .bodyValue(request.getStatements()) - return requestSpec.retrieve() + .retrieve() .toEntity(LIST_UUID_TYPE); 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 dca7f623..6ed363bc 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 @@ -16,7 +16,7 @@ import java.security.NoSuchAlgorithmException; import java.util.Locale; import lombok.Builder; -import lombok.ToString; +import lombok.EqualsAndHashCode; import lombok.Value; /** @@ -30,7 +30,7 @@ @Value @Builder @JsonInclude(Include.NON_EMPTY) -@ToString(exclude = "data") +@EqualsAndHashCode(of = "sha2") public class Attachment { /** @@ -87,15 +87,6 @@ public class Attachment { // **Warning** do not add fields that are not required by the xAPI specification. - /** - * The data of the attachment. - *

- * This is the actual String representation of the attachment as it appears in the http message. - *

- */ - @JsonIgnore - private String data; - /** * Builder for Attachment. */ @@ -229,7 +220,6 @@ private static String sha256Hex(byte[] data) { } } - } } From 22a4f508f1a6a15b5eb8d26b747b185d282fcecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Fri, 31 Mar 2023 10:05:10 +0100 Subject: [PATCH 19/27] fix more merge issues --- .../main/java/dev/learning/xapi/model/Statement.java | 2 +- .../java/dev/learning/xapi/model/StatementTests.java | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) 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 1f997feb..cd5ea51d 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 @@ -164,7 +164,7 @@ public Statement sign(PrivateKey privateKey) { .addDisplay(Locale.ENGLISH, "JSW signature") - .data(token) + .content(token) .length(token.length()) 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 06dcaebd..441a6e70 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 @@ -19,7 +19,7 @@ import jakarta.validation.Validator; import java.io.IOException; import java.net.URI; -import java.security.KeyPair; +import java.nio.charset.StandardCharsets; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.time.Instant; @@ -931,8 +931,9 @@ void whenSigningStatementThenSignatureIsExpected() throws NoSuchAlgorithmExcepti .sign(keyPair.getPrivate()); // Then Signature is Expected - assertThat(statement.getAttachments().get(0).getData(), startsWith( - "eyJhbGciOiJSUzUxMiJ9.eyJhY3RvciI6eyJuYW1lIjoiQSBOIE90aGVyIn0sInJlc3VsdCI6eyJzdWNjZXNzIjp0cnVlLCJjb21wbGV0aW9uIjp0cnVlLCJyZXNwb25zZSI6IlJlc3BvbnNlIiwiZHVyYXRpb24iOiJQMUQifSwidmVyYiI6eyJpZCI6Imh0dHA6Ly9leGFtcGxlLmNvbS94YXBpL3ZlcmJzI3NlbnQtYS1zdGF0ZW1lbnQiLCJkaXNwbGF5Ijp7ImVuLVVTIjoiYXR0ZW5kZWQifX0sImNvbnRleHQiOnsicmVnaXN0cmF0aW9uIjoiZWM1MzEyNzctYjU3Yi00YzE1LThkOTEtZDI5MmM1YjJiOGY3IiwiaW5zdHJ1Y3RvciI6eyJvYmplY3RUeXBlIjoiQWdlbnQiLCJuYW1lIjoiQSBOIE90aGVyIiwiYWNjb3VudCI6eyJob21lUGFnZSI6Imh0dHBzOi8vZXhhbXBsZS5jb20iLCJuYW1lIjoiMTM5MzY3NDkifX0sInRlYW0iOnsib2JqZWN0VHlwZSI6Ikdyb3VwIiwibmFtZSI6IlRlYW0iLCJtYm94IjoibWFpbHRvOnRlYW1AZXhhbXBsZS5jb20ifSwicGxhdGZvcm0iOiJFeGFtcGxlIHZpcnR1YWwgbWVldGluZyBzb2Z0d2FyZSIsImxhbmd1YWdlIjoiZW4iLCJzdGF0ZW1lbnQiOnsib2JqZWN0VHlwZSI6IlN0YXRlbWVudFJlZiIsImlkIjoiNjY5MGU2YzktM2VmMC00ZWQzLThiMzctN2YzOTY0NzMwYmVlIn19LCJvYmplY3QiOnsiaWQiOiJodHRwOi8vd3d3LmV4YW1wbGUuY29tL21lZXRpbmdzL29jY3VyYW5jZXMvMzQ1MzQiLCJkZWZpbml0aW9uIjp7Im5hbWUiOnsiZW4tR0IiOiJBIHNpbXBsZSBFeHBlcmllbmNlIEFQSSBzdGF0ZW1lbnQuIE5vdGUgdGhhdCB0aGUgTFJTIGRvZXMgbm90IG5lZWQgdG8gaGF2ZSBhbnkgcHJpb3IgaW5mb3JtYXRpb24gYWJvdXQgdGhlIEFjdG9yIChsZWFybmVyKSwgdGhlIHZlcmIsIG9yIHRoZSBBY3Rpdml0eS9vYmplY3QuIn0sImRlc2NyaXB0aW9uIjp7ImVuLUdCIjoiQSBzaW1wbGUgRXhwZXJpZW5jZSBBUEkgc3RhdGVtZW50LiBOb3RlIHRoYXQgdGhlIExSUyBkb2VzIG5vdCBuZWVkIHRvIGhhdmUgYW55IHByaW9yIGluZm9ybWF0aW9uIGFib3V0IHRoZSBBY3RvciAobGVhcm5lciksIHRoZSB2ZXJiLCBvciB0aGUgQWN0aXZpdHkvb2JqZWN0LiJ9LCJ0eXBlIjoiaHR0cDovL2FkbG5ldC5nb3YvZXhwYXBpL2FjdGl2aXRpZXMvbWVldGluZyIsIm1vcmVJbmZvIjoiaHR0cDovL3ZpcnR1YWxtZWV0aW5nLmV4YW1wbGUuY29tLzM0NTI1NiIsImV4dGVuc2lvbnMiOnsiaHR0cDovL25hbWUiOiJLaWxieSJ9fX19.")); + assertThat(new String(statement.getAttachments().get(0).getContent(), StandardCharsets.UTF_8), + startsWith( + "eyJhbGciOiJSUzUxMiJ9.eyJhY3RvciI6eyJuYW1lIjoiQSBOIE90aGVyIn0sInJlc3VsdCI6eyJzdWNjZXNzIjp0cnVlLCJjb21wbGV0aW9uIjp0cnVlLCJyZXNwb25zZSI6IlJlc3BvbnNlIiwiZHVyYXRpb24iOiJQMUQifSwidmVyYiI6eyJpZCI6Imh0dHA6Ly9leGFtcGxlLmNvbS94YXBpL3ZlcmJzI3NlbnQtYS1zdGF0ZW1lbnQiLCJkaXNwbGF5Ijp7ImVuLVVTIjoiYXR0ZW5kZWQifX0sImNvbnRleHQiOnsicmVnaXN0cmF0aW9uIjoiZWM1MzEyNzctYjU3Yi00YzE1LThkOTEtZDI5MmM1YjJiOGY3IiwiaW5zdHJ1Y3RvciI6eyJvYmplY3RUeXBlIjoiQWdlbnQiLCJuYW1lIjoiQSBOIE90aGVyIiwiYWNjb3VudCI6eyJob21lUGFnZSI6Imh0dHBzOi8vZXhhbXBsZS5jb20iLCJuYW1lIjoiMTM5MzY3NDkifX0sInRlYW0iOnsib2JqZWN0VHlwZSI6Ikdyb3VwIiwibmFtZSI6IlRlYW0iLCJtYm94IjoibWFpbHRvOnRlYW1AZXhhbXBsZS5jb20ifSwicGxhdGZvcm0iOiJFeGFtcGxlIHZpcnR1YWwgbWVldGluZyBzb2Z0d2FyZSIsImxhbmd1YWdlIjoiZW4iLCJzdGF0ZW1lbnQiOnsib2JqZWN0VHlwZSI6IlN0YXRlbWVudFJlZiIsImlkIjoiNjY5MGU2YzktM2VmMC00ZWQzLThiMzctN2YzOTY0NzMwYmVlIn19LCJvYmplY3QiOnsiaWQiOiJodHRwOi8vd3d3LmV4YW1wbGUuY29tL21lZXRpbmdzL29jY3VyYW5jZXMvMzQ1MzQiLCJkZWZpbml0aW9uIjp7Im5hbWUiOnsiZW4tR0IiOiJBIHNpbXBsZSBFeHBlcmllbmNlIEFQSSBzdGF0ZW1lbnQuIE5vdGUgdGhhdCB0aGUgTFJTIGRvZXMgbm90IG5lZWQgdG8gaGF2ZSBhbnkgcHJpb3IgaW5mb3JtYXRpb24gYWJvdXQgdGhlIEFjdG9yIChsZWFybmVyKSwgdGhlIHZlcmIsIG9yIHRoZSBBY3Rpdml0eS9vYmplY3QuIn0sImRlc2NyaXB0aW9uIjp7ImVuLUdCIjoiQSBzaW1wbGUgRXhwZXJpZW5jZSBBUEkgc3RhdGVtZW50LiBOb3RlIHRoYXQgdGhlIExSUyBkb2VzIG5vdCBuZWVkIHRvIGhhdmUgYW55IHByaW9yIGluZm9ybWF0aW9uIGFib3V0IHRoZSBBY3RvciAobGVhcm5lciksIHRoZSB2ZXJiLCBvciB0aGUgQWN0aXZpdHkvb2JqZWN0LiJ9LCJ0eXBlIjoiaHR0cDovL2FkbG5ldC5nb3YvZXhwYXBpL2FjdGl2aXRpZXMvbWVldGluZyIsIm1vcmVJbmZvIjoiaHR0cDovL3ZpcnR1YWxtZWV0aW5nLmV4YW1wbGUuY29tLzM0NTI1NiIsImV4dGVuc2lvbnMiOnsiaHR0cDovL25hbWUiOiJLaWxieSJ9fX19.")); } @@ -1010,7 +1011,9 @@ void whenSigningStatementThenSignatureIsValid() // Then Signature is Valid final var body = Jwts.parserBuilder().setSigningKey(keyPair.getPublic()).build() - .parseClaimsJws(statement.getAttachments().get(0).getData()).getBody(); + .parseClaimsJws( + new String(statement.getAttachments().get(0).getContent(), StandardCharsets.UTF_8)) + .getBody(); final var bodyStatement = objectMapper.readValue(objectMapper.writeValueAsString(body), Statement.class); From 65b68b15e1c31f6e9655c19efb25ecabba537a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 17 Apr 2023 10:23:51 +0100 Subject: [PATCH 20/27] rename sign to signAndBuild --- .../src/main/java/dev/learning/xapi/model/Statement.java | 2 +- .../test/java/dev/learning/xapi/model/StatementTests.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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 cd5ea51d..f19137c4 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 @@ -146,7 +146,7 @@ public static class Builder { * "https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#26-signed-statements"> * Signed Statements */ - public Statement sign(PrivateKey privateKey) { + public Statement signAndBuild(PrivateKey privateKey) { Map claims = new HashMap<>(); // Put only the significant properties into the signature payload 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 8ffd93a4..b83125a5 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 @@ -1363,7 +1363,7 @@ void whenSigningStatementThenSignatureIsAddedAsAttachment() throws NoSuchAlgorit .version("1.0.0") - .sign(keyPair.getPrivate()); + .signAndBuild(keyPair.getPrivate()); // Then Signature is Added As Attachment assertThat(statement.getAttachments(), hasSize(1)); @@ -1457,7 +1457,7 @@ void whenSigningStatementWithAttachmentThenSignatureIsAddedAsAttachment() .version("1.0.0") - .sign(keyPair.getPrivate()); + .signAndBuild(keyPair.getPrivate()); // Then Signature is Added As Attachment assertThat(statement.getAttachments(), hasSize(2)); @@ -1533,7 +1533,7 @@ void whenSigningStatementThenSignatureIsExpected() throws NoSuchAlgorithmExcepti .version("1.0.0") - .sign(keyPair.getPrivate()); + .signAndBuild(keyPair.getPrivate()); // Then Signature is Expected assertThat(new String(statement.getAttachments().get(0).getContent(), StandardCharsets.UTF_8), @@ -1612,7 +1612,7 @@ void whenSigningStatementThenSignatureIsValid() .version("1.0.0") - .sign(keyPair.getPrivate()); + .signAndBuild(keyPair.getPrivate()); // Then Signature is Valid final var body = Jwts.parserBuilder().setSigningKey(keyPair.getPublic()).build() From a85af5b679ae71498620e20be7d068c61d37687b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 17 Apr 2023 11:29:45 +0100 Subject: [PATCH 21/27] add post signed statement sample --- samples/pom.xml | 1 + samples/post-signed-statement/pom.xml | 30 +++++++++ .../PostSignedStatementApplication.java | 64 +++++++++++++++++++ .../src/main/resources/application.properties | 4 ++ .../xapi/client/PostStatementRequest.java | 21 ++++++ xapi-model/pom.xml | 4 +- .../dev/learning/xapi/model/Statement.java | 43 +++++++++---- 7 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 samples/post-signed-statement/pom.xml create mode 100644 samples/post-signed-statement/src/main/java/dev/learning/xapi/samples/poststatement/PostSignedStatementApplication.java create mode 100644 samples/post-signed-statement/src/main/resources/application.properties diff --git a/samples/pom.xml b/samples/pom.xml index bb70b9ec..e8dc9580 100644 --- a/samples/pom.xml +++ b/samples/pom.xml @@ -37,6 +37,7 @@ get-statement get-statement-with-attachment post-statement + post-signed-statement post-statement-with-attachment get-statements get-more-statements diff --git a/samples/post-signed-statement/pom.xml b/samples/post-signed-statement/pom.xml new file mode 100644 index 00000000..29e6f779 --- /dev/null +++ b/samples/post-signed-statement/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + dev.learning.xapi.samples + xapi-samples-build + 1.1.4-SNAPSHOT + + post-signed-statement + Post xAPI Signed Statement Sample + Post xAPI Signed Statement + + + dev.learning.xapi + xapi-client + + + dev.learning.xapi.samples + core + + + io.jsonwebtoken + jjwt-impl + + + io.jsonwebtoken + jjwt-jackson + + + diff --git a/samples/post-signed-statement/src/main/java/dev/learning/xapi/samples/poststatement/PostSignedStatementApplication.java b/samples/post-signed-statement/src/main/java/dev/learning/xapi/samples/poststatement/PostSignedStatementApplication.java new file mode 100644 index 00000000..27db8f3f --- /dev/null +++ b/samples/post-signed-statement/src/main/java/dev/learning/xapi/samples/poststatement/PostSignedStatementApplication.java @@ -0,0 +1,64 @@ +/* + * 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.security.KeyPairGenerator; +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; + +/** + * Sample using xAPI client to post a statement. + * + * @author Thomas Turrell-Croft + * @author István Rátkai (Selindek) + */ +@SpringBootApplication +public class PostSignedStatementApplication implements CommandLineRunner { + + /** + * Default xAPI client. Properties are picked automatically from application.properties. + */ + @Autowired + private XapiClient client; + + public static void main(String[] args) { + SpringApplication.run(PostSignedStatementApplication.class, args).close(); + } + + @Override + public void run(String... args) throws Exception { + + final var keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + final var keyPair = keyPairGenerator.generateKeyPair(); + + // Post a statement + ResponseEntity< + UUID> response = + client + .postStatement(r -> r.signedStatement( + 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"))), + + keyPair.getPrivate())) + .block(); + + // Print the statementId of the newly created statement to the console + System.out.println("StatementId " + response.getBody()); + } + +} diff --git a/samples/post-signed-statement/src/main/resources/application.properties b/samples/post-signed-statement/src/main/resources/application.properties new file mode 100644 index 00000000..edcac831 --- /dev/null +++ b/samples/post-signed-statement/src/main/resources/application.properties @@ -0,0 +1,4 @@ +xapi.client.username = admin +xapi.client.password = password +xapi.client.baseUrl = http://localhost:8081/xapi/ +#xapi.client.baseUrl = https://example.com/xapi/ diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementRequest.java b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementRequest.java index 1cafb1b6..65c2a22a 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementRequest.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementRequest.java @@ -5,6 +5,7 @@ package dev.learning.xapi.client; import dev.learning.xapi.model.Statement; +import java.security.PrivateKey; import java.util.Map; import java.util.function.Consumer; import lombok.Builder; @@ -64,6 +65,26 @@ public Builder statement(Consumer statement) { return statement(builder.build()); } + /** + * Consumer Builder for signed statement. + * + * @param statement The Consumer Builder for signed-statement + * + * @paraam privateKey a PrivateKey for signing the Statement + * + * @return This builder + * + * @see PostStatementRequest#statement + */ + public Builder signedStatement(Consumer statement, PrivateKey privateKey) { + + final var builder = Statement.builder(); + + statement.accept(builder); + + return statement(builder.signAndBuild(privateKey)); + } + /** * Sets the statement. * diff --git a/xapi-model/pom.xml b/xapi-model/pom.xml index a19a05f9..643641f9 100644 --- a/xapi-model/pom.xml +++ b/xapi-model/pom.xml @@ -17,12 +17,12 @@ io.jsonwebtoken jjwt-impl - runtime + true io.jsonwebtoken jjwt-jackson - runtime + true com.fasterxml.jackson.core 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 f19137c4..fe8aea49 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 @@ -15,6 +15,7 @@ import dev.learning.xapi.model.validation.constraints.Variant; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.lang.UnknownClassException; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; @@ -141,13 +142,15 @@ public static class Builder { *

* * @param privateKey a {@link PrivateKey} for signing the {@link Statement}. + * * @return an immutable, signed {@link Statement} object. + * * @see * Signed Statements */ public Statement signAndBuild(PrivateKey privateKey) { - Map claims = new HashMap<>(); + final Map claims = new HashMap<>(); // Put only the significant properties into the signature payload // https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#statement-comparision-requirements @@ -157,22 +160,40 @@ public Statement signAndBuild(PrivateKey privateKey) { claims.put("result", this.result); claims.put("context", this.context); - String token = Jwts.builder().setClaims(claims).signWith(privateKey, SignatureAlgorithm.RS512) - .compact(); + try { + final var token = Jwts.builder().setClaims(claims) + .signWith(privateKey, SignatureAlgorithm.RS512).compact(); + + addAttachment(a -> a.usageType(URI.create("http://adlnet.gov/expapi/attachments/signature")) + + .addDisplay(Locale.ENGLISH, "JSW signature") + + .content(token) - addAttachment(a -> a.usageType(URI.create("http://adlnet.gov/expapi/attachments/signature")) + .length(token.length()) - .addDisplay(Locale.ENGLISH, "JSW signature") + .contentType("application/octet-stream")); - .content(token) - - .length(token.length()) + } catch (final UnknownClassException e) { + throw new IllegalStateException(""" + + Statement cannot be signed, because an optional dependency was NOT provided. + Please add the following dependencies into your project: + + + io.jsonwebtoken + jjwt-impl + + + io.jsonwebtoken + jjwt-jackson + + """, e); + } - .contentType("application/octet-stream")); - return build(); } - + /** * Consumer Builder for agent. * From 27ae2d904a63179f797b9de31e13bc59384ee521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 17 Apr 2023 13:30:50 +0100 Subject: [PATCH 22/27] fcs --- .../xapi/client/PostStatementRequest.java | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementRequest.java b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementRequest.java index 65c2a22a..17a7b6fe 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementRequest.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementRequest.java @@ -66,41 +66,40 @@ public Builder statement(Consumer statement) { } /** - * Consumer Builder for signed statement. - * - * @param statement The Consumer Builder for signed-statement + * Sets the statement. * - * @paraam privateKey a PrivateKey for signing the Statement + * @param statement The Statement to post * * @return This builder * * @see PostStatementRequest#statement */ - public Builder signedStatement(Consumer statement, PrivateKey privateKey) { - - final var builder = Statement.builder(); + public Builder statement(Statement statement) { - statement.accept(builder); + this.statement = statement; - return statement(builder.signAndBuild(privateKey)); + return this; } /** - * Sets the statement. + * Consumer Builder for signed statement. * - * @param statement The Statement to post + * @param statement The Consumer Builder for signed-statement + * + * @paraam privateKey a PrivateKey for signing the Statement * * @return This builder * * @see PostStatementRequest#statement */ - public Builder statement(Statement statement) { + public Builder signedStatement(Consumer statement, PrivateKey privateKey) { - this.statement = statement; + final var builder = Statement.builder(); - return this; - } + statement.accept(builder); + return statement(builder.signAndBuild(privateKey)); + } } } From c23494e5765f05ceeda0e239ae60e9c12ce77933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 17 Apr 2023 13:36:41 +0100 Subject: [PATCH 23/27] fcs --- .../poststatement/PostSignedStatementApplication.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/samples/post-signed-statement/src/main/java/dev/learning/xapi/samples/poststatement/PostSignedStatementApplication.java b/samples/post-signed-statement/src/main/java/dev/learning/xapi/samples/poststatement/PostSignedStatementApplication.java index 27db8f3f..7a77bf8c 100644 --- a/samples/post-signed-statement/src/main/java/dev/learning/xapi/samples/poststatement/PostSignedStatementApplication.java +++ b/samples/post-signed-statement/src/main/java/dev/learning/xapi/samples/poststatement/PostSignedStatementApplication.java @@ -6,7 +6,6 @@ import dev.learning.xapi.client.XapiClient; import dev.learning.xapi.model.Verb; - import java.security.KeyPairGenerator; import java.util.Locale; import java.util.UUID; @@ -18,7 +17,10 @@ /** * Sample using xAPI client to post a statement. - * + *

+ * See pom.xml for extra dependencies. + *

+ * * @author Thomas Turrell-Croft * @author István Rátkai (Selindek) */ From 0550cfcf033b0bd617903dfb8de9cb3a4c2cdaa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 17 Apr 2023 13:42:31 +0100 Subject: [PATCH 24/27] add paragraph to README.md --- README.md | 17 +++++++++++++++++ .../PostSignedStatementApplication.java | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b5f4609c..8c422178 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,23 @@ client.postStatement( )).block(); ``` +### Posting a Signed Statement + +Example: + +```java +client.postStatement( + r -> r.signedStatement(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"))), + + keyPair.getPrivate())) + .block(); +``` + ### Posting Statements Example: diff --git a/samples/post-signed-statement/src/main/java/dev/learning/xapi/samples/poststatement/PostSignedStatementApplication.java b/samples/post-signed-statement/src/main/java/dev/learning/xapi/samples/poststatement/PostSignedStatementApplication.java index 7a77bf8c..3d8a61f7 100644 --- a/samples/post-signed-statement/src/main/java/dev/learning/xapi/samples/poststatement/PostSignedStatementApplication.java +++ b/samples/post-signed-statement/src/main/java/dev/learning/xapi/samples/poststatement/PostSignedStatementApplication.java @@ -20,7 +20,7 @@ *

* See pom.xml for extra dependencies. *

- * + * * @author Thomas Turrell-Croft * @author István Rátkai (Selindek) */ From 6905db25712ee02d0a215ddb3e4a62f4855f7256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 17 Apr 2023 14:01:30 +0100 Subject: [PATCH 25/27] add test --- .../learning/xapi/client/XapiClientTests.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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 d06ab622..e689a0aa 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 @@ -6,6 +6,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.Is.is; import static org.hamcrest.core.IsInstanceOf.instanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; import dev.learning.xapi.model.About; import dev.learning.xapi.model.Activity; @@ -14,6 +15,8 @@ import dev.learning.xapi.model.StatementFormat; import dev.learning.xapi.model.Verb; import java.net.URI; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; import java.time.Instant; import java.util.Arrays; import java.util.List; @@ -364,6 +367,38 @@ void whenPostingStatementThenContentTypeHeaderIsApplicationJson() throws Interru assertThat(recordedRequest.getHeader("content-type"), is("application/json")); } + // Posting a Signed Statement + + @Test + void whenPostingSignedStatementThenExceptionIsThrown() throws NoSuchAlgorithmException { + + mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK") + .setBody("[\"19a74a3f-7354-4254-aa4a-1c39ab4f2ca7\"]") + .setHeader("Content-Type", "application/json")); + + final var keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + final var keyPair = keyPairGenerator.generateKeyPair(); + + // When posting Signed Statement Then Exception Is Thrown + // ( Signing statements requires additional dependencies which are + // NOT included in these tests by default. ) + assertThrows(IllegalStateException.class, + () -> client.postStatement(r -> r + .signedStatement( + 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"))), + + keyPair.getPrivate()) + + .build()).block()); + + } + // Get Voided Statement @Test From abf8d31673c9f82c909d6d7ffc33782eb61a856d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 17 Apr 2023 14:14:16 +0100 Subject: [PATCH 26/27] fsi --- .../src/test/java/dev/learning/xapi/client/XapiClientTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e689a0aa..cb34f993 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 @@ -395,7 +395,7 @@ void whenPostingSignedStatementThenExceptionIsThrown() throws NoSuchAlgorithmExc keyPair.getPrivate()) - .build()).block()); + .build())); } From 2ce5aeb3005feaf9567eaf6dedab99fece42ab4c Mon Sep 17 00:00:00 2001 From: Thomas Turrell-Croft Date: Mon, 17 Apr 2023 15:52:06 +0100 Subject: [PATCH 27/27] Apply suggestions from code review --- .../src/main/resources/application.properties | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/samples/post-signed-statement/src/main/resources/application.properties b/samples/post-signed-statement/src/main/resources/application.properties index edcac831..de20217a 100644 --- a/samples/post-signed-statement/src/main/resources/application.properties +++ b/samples/post-signed-statement/src/main/resources/application.properties @@ -1,4 +1,3 @@ xapi.client.username = admin xapi.client.password = password -xapi.client.baseUrl = http://localhost:8081/xapi/ -#xapi.client.baseUrl = https://example.com/xapi/ +xapi.client.baseUrl = https://example.com/xapi/