diff --git a/README.md b/README.md index 6f05967f..550a79e2 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,16 @@ var response = client.getStatement(r -> r.id("4df42866-40e7-45b6-bf7c-8d5fccbdcc Statement statement = response.getBody(); ``` +### Getting a Statement with attachments + +Example: + +```java +var response = client.getStatement(r -> r.id("4df42866-40e7-45b6-bf7c-8d5fccbdccd6").attachments(true).block(); + +Statement statement = response.getBody(); +``` + ### Getting Statements Example: diff --git a/samples/get-statement-with-attachment/pom.xml b/samples/get-statement-with-attachment/pom.xml new file mode 100644 index 00000000..aaf2d627 --- /dev/null +++ b/samples/get-statement-with-attachment/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + + dev.learning.xapi.samples + xapi-samples-build + 1.1.2-SNAPSHOT + + get-statement-with-attachment + Get xAPI Statement With Attachment Sample + Get xAPI Statement With Attachment + + + dev.learning.xapi + xapi-client + + + dev.learning.xapi.samples + core + + + diff --git a/samples/get-statement-with-attachment/src/main/java/dev/learning/xapi/samples/poststatement/GetStatementWithAttachmentApplication.java b/samples/get-statement-with-attachment/src/main/java/dev/learning/xapi/samples/poststatement/GetStatementWithAttachmentApplication.java new file mode 100644 index 00000000..b751ba88 --- /dev/null +++ b/samples/get-statement-with-attachment/src/main/java/dev/learning/xapi/samples/poststatement/GetStatementWithAttachmentApplication.java @@ -0,0 +1,100 @@ +/* + * 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.Statement; +import dev.learning.xapi.model.Verb; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Locale; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.http.ResponseEntity; +import org.springframework.util.ResourceUtils; + +/** + * Sample using xAPI client to get a statement with attachments. + * + * @author Thomas Turrell-Croft + * @author István Rátkai (Selindek) + */ +@SpringBootApplication +public class GetStatementWithAttachmentApplication implements CommandLineRunner { + + /** + * Default xAPI client. Properties are picked automatically from application.properties. + */ + @Autowired + private XapiClient client; + + public static void main(String[] args) { + SpringApplication.run(GetStatementWithAttachmentApplication.class, args).close(); + } + + @Override + public void run(String... args) throws Exception { + + // Post a test statement with attachments + var id = postStatement(); + + // Get Statement + ResponseEntity response = + client.getStatement(r -> r.id(id).attachments(true)).block(); + + // If the attachment parameter is set to true in a getStatement (or a getStatements) request + // then the server will send the response in a multipart/mixed format (even if the + // Statement doesn't have attachments.) The xApi client automatically converts these responses + // back to the regular Statement / StatementResponse format and populate the returned + // statement's or statements' attachments' content from the additional parts from the response. + + // Print the returned statement's attachments to the console + System.out.println(new String(response.getBody().getAttachments().get(0).getContent())); + + System.out.println(Arrays.toString(response.getBody().getAttachments().get(1).getContent())); + + } + + private UUID postStatement() throws FileNotFoundException, IOException { + + // Load jpg attachment from class-path + var data = Files.readAllBytes(ResourceUtils.getFile("classpath:example.jpg").toPath()); + + // Post a statement + ResponseEntity< + UUID> response = + client + .postStatement(r -> r.statement( + s -> s.actor(a -> a.name("A N Other").mbox("mailto:another@example.com")) + + .verb(Verb.ATTEMPTED) + + .activityObject(o -> o.id("https://example.com/activity/simplestatement") + .definition(d -> d.addName(Locale.ENGLISH, "Simple Statement"))) + + // Add simple text attachment + .addAttachment(a -> a.content("Simple attachment").length(17) + .contentType("text/plain") + .usageType(URI.create("https://example.com/attachments/greeting")) + .addDisplay(Locale.ENGLISH, "text attachment")) + + // Add binary attachment + .addAttachment(a -> a.content(data).length(data.length) + .contentType("image/jpeg") + .usageType(URI.create("https://example.com/attachments/greeting")) + .addDisplay(Locale.ENGLISH, "JPEG attachment")) + + )).block(); + + return response.getBody(); + } + +} diff --git a/samples/get-statement-with-attachment/src/main/resources/application.properties b/samples/get-statement-with-attachment/src/main/resources/application.properties new file mode 100644 index 00000000..de20217a --- /dev/null +++ b/samples/get-statement-with-attachment/src/main/resources/application.properties @@ -0,0 +1,3 @@ +xapi.client.username = admin +xapi.client.password = password +xapi.client.baseUrl = https://example.com/xapi/ diff --git a/samples/get-statement-with-attachment/src/main/resources/example.jpg b/samples/get-statement-with-attachment/src/main/resources/example.jpg new file mode 100644 index 00000000..82123354 Binary files /dev/null and b/samples/get-statement-with-attachment/src/main/resources/example.jpg differ diff --git a/samples/pom.xml b/samples/pom.xml index 34cfb6fc..d4101ab3 100644 --- a/samples/pom.xml +++ b/samples/pom.xml @@ -35,6 +35,7 @@ core get-statement + get-statement-with-attachment post-statement post-statement-with-attachment get-statements diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageReader.java b/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageReader.java new file mode 100644 index 00000000..3700b51d --- /dev/null +++ b/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageReader.java @@ -0,0 +1,144 @@ +/* + * Copyright 2016-2023 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.client; + +import dev.learning.xapi.model.Attachment; +import dev.learning.xapi.model.Statement; +import dev.learning.xapi.model.StatementResult; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.ReactiveHttpInputMessage; +import org.springframework.http.codec.HttpMessageReader; +import org.springframework.http.codec.LoggingCodecSupport; +import org.springframework.http.codec.multipart.DefaultPartHttpMessageReader; +import org.springframework.http.codec.multipart.Part; +import org.springframework.lang.Nullable; +import org.springframework.web.reactive.function.client.ClientResponse; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * {@link HttpMessageReader} for reading {@code "multipart/mixed"} responses into a + * {@link Statement} or {@link StatementResult}s object. + * + * @author István Rátkai (Selindek) + */ +public class StatementHttpMessageReader extends LoggingCodecSupport + implements HttpMessageReader { + + static final List MIME_TYPES = List.of(MediaType.MULTIPART_MIXED); + + private final HttpMessageReader partReader = new DefaultPartHttpMessageReader(); + + + @Override + public List getReadableMediaTypes() { + return MIME_TYPES; + } + + @Override + public boolean canRead(ResolvableType elementType, @Nullable MediaType mediaType) { + if (Statement.class.equals(elementType.toClass()) + || StatementResult.class.equals(elementType.toClass())) { + if (mediaType == null) { + return true; + } + for (final MediaType supportedMediaType : MIME_TYPES) { + if (supportedMediaType.isCompatibleWith(mediaType)) { + return true; + } + } + } + return false; + } + + + @Override + public Flux read(ResolvableType elementType, ReactiveHttpInputMessage message, + Map hints) { + + return Flux.from(readMono(elementType, message, hints)); + } + + + @Override + public Mono readMono(ResolvableType elementType, ReactiveHttpInputMessage inputMessage, + Map hints) { + + return this.partReader.read(elementType, inputMessage, hints).collectList() + .flatMap(list -> toStatement(elementType, list)); + } + + private Mono toStatement(ResolvableType elementType, List parts) { + + if (parts.isEmpty()) { + return null; + } + + final var jsonPart = parts.get(0); + final var jsonType = jsonPart.headers().getContentType(); + + if (!MediaType.APPLICATION_JSON.isCompatibleWith(jsonType)) { + return null; + } + + // Create a virtual response from the the first (json) part... + final var jsonResponse = ClientResponse.create(HttpStatusCode.valueOf(200)) + .body(jsonPart.content()).headers(headers -> headers.addAll(jsonPart.headers())).build(); + + // ... and use the default extractors to extract its content to a Statement/StatementResult + Flux partDataFlux = jsonResponse.bodyToFlux(elementType.toClass()); + + // merge the attachment parts contents with it + for (var i = 1; i < parts.size(); i++) { + partDataFlux = partDataFlux.mergeWith(parts.get(i).content()); + } + + // Now we have direct access to all the data + return partDataFlux.collectList().map(partData -> { + // the first part's data is the Statement / StatementResult + final var object = partData.get(0); + final var statements = object instanceof final Statement statement ? Arrays.asList(statement) + : ((StatementResult) object).getStatements(); + + for (var i = 1; i < partData.size(); i++) { + final var buffer = (DataBuffer) partData.get(i); + final var content = new byte[buffer.readableByteCount()]; + buffer.read(content); + DataBufferUtils.release(buffer); + final var sha2 = parts.get(i).headers().getFirst("X-Experience-API-Hash"); + injectAttachmentContent(statements, sha2, content); + } + + return object; + }); + + } + + /** + * Inject the content into each {@link Attachment} in each statements with the matching sha2. + */ + private void injectAttachmentContent(List statements, String sha2, byte[] content) { + for (final var statement : statements) { + final var attachments = statement.getAttachments(); + if (attachments != null) { + final var size = attachments.size(); + for (var i = 0; i < size; i++) { + final var attachment = attachments.get(i); + if (sha2.equals(attachment.getSha2())) { + attachments.set(i, attachment.withContent(content)); + } + } + } + } + } + +} 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..0de6facd 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 @@ -51,11 +51,14 @@ public XapiClient(WebClient.Builder builder) { .defaultHeader("X-Experience-API-Version", "1.0.3") - .codecs(configurer -> + .codecs(configurer -> { - configurer.customCodecs().register(new StatementHttpMessageWriter(configurer.getWriters())) + configurer.customCodecs() + .register(new StatementHttpMessageWriter(configurer.getWriters())); - ).build(); + configurer.customCodecs().register(new StatementHttpMessageReader()); + + }).build(); } // Statement Resource 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..f582e44e 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 @@ -5,12 +5,14 @@ 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 static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import dev.learning.xapi.model.Activity; import dev.learning.xapi.model.Agent; import dev.learning.xapi.model.Statement; +import dev.learning.xapi.model.StatementResult; import dev.learning.xapi.model.SubStatement; import dev.learning.xapi.model.Verb; import java.net.URI; @@ -318,6 +320,85 @@ void whenPostingStatementsWithTimestampAndAttachmentThenNoExceptionIsThrown() } + @Test + void whenGettingStatementWithAttachmentThenResponseIsExpected() throws InterruptedException { + + // single statement with two attachments + final var body = + """ + ---------314159265358979323846 + Content-Type:application/json + + {"id":"183aabbe-ef9e-49c9-82a3-16ce5135b25b","actor":{"name":"A N Other","mbox":"mailto:another@example.com","objectType":"Agent"},"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"}}},"timestamp":"2023-03-29T12:42:27.923571Z","stored":"2023-03-29T12:42:27.923571Z","authority":{"account":{"homePage":"http://localhost","name":"admin"},"objectType":"Agent"},"attachments":[{"usageType":"http://adlnet.gov/expapi/attachments/code","display":{"en":"binary attachment"},"contentType":"application/octet-stream","length":6,"sha2":"0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef"},{"usageType":"http://adlnet.gov/expapi/attachments/text","display":{"en":"text attachment"},"contentType":"text/plain","length":17,"sha2":"b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5"}]} + ---------314159265358979323846 + Content-Type:application/octet-stream + Content-Transfer-Encoding:binary + X-Experience-API-Hash:0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef + + @ABCDE + ---------314159265358979323846 + Content-Type:text/plain + Content-Transfer-Encoding:binary + X-Experience-API-Hash:b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5 + + Simple attachment + ---------314159265358979323846--""" + .replace("\n", "\r\n"); + + mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK") + + .setBody(body) + + .addHeader("Content-Type", "multipart/mixed; boundary=-------314159265358979323846")); + + // When Getting Statement With Attachment + final var response = client + .getStatement(r -> r.id("183aabbe-ef9e-49c9-82a3-16ce5135b25b").attachments(true)).block(); + + // Then Response Is Expected + assertThat(response.getBody(), instanceOf(Statement.class)); + assertThat(response.getBody().toString(), is( + "Statement(id=183aabbe-ef9e-49c9-82a3-16ce5135b25b, actor=Agent(super=Actor(name=A N Other, mbox=mailto:another@example.com, mboxSha1sum=null, openid=null, account=null)), verb=Verb(id=http://adlnet.gov/expapi/verbs/attempted, display={und=attempted}), object=Activity(id=https://example.com/activity/simplestatement, definition=ActivityDefinition(name={en=Simple Statement}, description=null, type=null, moreInfo=null, interactionType=null, correctResponsesPattern=null, choices=null, scale=null, source=null, target=null, steps=null, extensions=null)), result=null, context=null, timestamp=2023-03-29T12:42:27.923571Z, stored=2023-03-29T12:42:27.923571Z, authority=Agent(super=Actor(name=null, mbox=null, mboxSha1sum=null, openid=null, account=Account(homePage=http://localhost, name=admin))), version=null, attachments=[Attachment(usageType=http://adlnet.gov/expapi/attachments/code, display={en=binary attachment}, description=null, contentType=application/octet-stream, length=6, sha2=0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef, fileUrl=null, content=[64, 65, 66, 67, 68, 69]), Attachment(usageType=http://adlnet.gov/expapi/attachments/text, display={en=text attachment}, description=null, contentType=text/plain, length=17, sha2=b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5, fileUrl=null, content=[83, 105, 109, 112, 108, 101, 32, 97, 116, 116, 97, 99, 104, 109, 101, 110, 116])])")); + } + @Test + void whenGettingStatementsWithAttachmentsThenResponseIsExpected() throws InterruptedException { + + // two statements with overlapping attachments + final var body = + """ + ---------314159265358979323846 + Content-Type:application/json + + {"statements":[{"id":"183aabbe-ef9e-49c9-82a3-16ce5135b25b","actor":{"name":"A N Other","mbox":"mailto:another@example.com","objectType":"Agent"},"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"}}},"timestamp":"2023-03-29T12:42:27.923571Z","stored":"2023-03-29T12:42:27.923571Z","authority":{"account":{"homePage":"http://localhost","name":"admin"},"objectType":"Agent"},"attachments":[{"usageType":"http://adlnet.gov/expapi/attachments/code","display":{"en":"binary attachment"},"contentType":"application/octet-stream","length":6,"sha2":"0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef"},{"usageType":"http://adlnet.gov/expapi/attachments/text","display":{"en":"text attachment"},"contentType":"text/plain","length":17,"sha2":"b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5"}]},{"id":"bbd3babf-61bf-4038-81fe-8342a4cea9bf","actor":{"name":"A N Other","mbox":"mailto:another@example.com","objectType":"Agent"},"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"}}},"timestamp":"2023-03-29T12:42:27.923571Z","stored":"2023-03-29T12:42:27.923571Z","authority":{"account":{"homePage":"http://localhost","name":"admin"},"objectType":"Agent"},"attachments":[{"usageType":"http://adlnet.gov/expapi/attachments/code","display":{"en":"binary attachment"},"contentType":"application/octet-stream","length":6,"sha2":"0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef"}]}]} + ---------314159265358979323846 + Content-Type:application/octet-stream + Content-Transfer-Encoding:binary + X-Experience-API-Hash:0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef + + @ABCDE + ---------314159265358979323846 + Content-Type:text/plain + Content-Transfer-Encoding:binary + X-Experience-API-Hash:b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5 + + Simple attachment + ---------314159265358979323846--""" + .replace("\n", "\r\n"); + mockWebServer.enqueue(new MockResponse().setStatus("HTTP/1.1 200 OK") + + .setBody(body) + + .addHeader("Content-Type", "multipart/mixed; boundary=-------314159265358979323846")); + + // When Getting Statements With Attachment + final var response = client.getStatements(r -> r.attachments(true)).block(); + + // Then Response Is Expected + assertThat(response.getBody(), instanceOf(StatementResult.class)); + assertThat(response.getBody().toString(), is( + "StatementResult(statements=[Statement(id=183aabbe-ef9e-49c9-82a3-16ce5135b25b, actor=Agent(super=Actor(name=A N Other, mbox=mailto:another@example.com, mboxSha1sum=null, openid=null, account=null)), verb=Verb(id=http://adlnet.gov/expapi/verbs/attempted, display={und=attempted}), object=Activity(id=https://example.com/activity/simplestatement, definition=ActivityDefinition(name={en=Simple Statement}, description=null, type=null, moreInfo=null, interactionType=null, correctResponsesPattern=null, choices=null, scale=null, source=null, target=null, steps=null, extensions=null)), result=null, context=null, timestamp=2023-03-29T12:42:27.923571Z, stored=2023-03-29T12:42:27.923571Z, authority=Agent(super=Actor(name=null, mbox=null, mboxSha1sum=null, openid=null, account=Account(homePage=http://localhost, name=admin))), version=null, attachments=[Attachment(usageType=http://adlnet.gov/expapi/attachments/code, display={en=binary attachment}, description=null, contentType=application/octet-stream, length=6, sha2=0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef, fileUrl=null, content=[64, 65, 66, 67, 68, 69]), Attachment(usageType=http://adlnet.gov/expapi/attachments/text, display={en=text attachment}, description=null, contentType=text/plain, length=17, sha2=b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5, fileUrl=null, content=[83, 105, 109, 112, 108, 101, 32, 97, 116, 116, 97, 99, 104, 109, 101, 110, 116])]), Statement(id=bbd3babf-61bf-4038-81fe-8342a4cea9bf, actor=Agent(super=Actor(name=A N Other, mbox=mailto:another@example.com, mboxSha1sum=null, openid=null, account=null)), verb=Verb(id=http://adlnet.gov/expapi/verbs/attempted, display={und=attempted}), object=Activity(id=https://example.com/activity/simplestatement, definition=ActivityDefinition(name={en=Simple Statement}, description=null, type=null, moreInfo=null, interactionType=null, correctResponsesPattern=null, choices=null, scale=null, source=null, target=null, steps=null, extensions=null)), result=null, context=null, timestamp=2023-03-29T12:42:27.923571Z, stored=2023-03-29T12:42:27.923571Z, authority=Agent(super=Actor(name=null, mbox=null, mboxSha1sum=null, openid=null, account=Account(homePage=http://localhost, name=admin))), version=null, attachments=[Attachment(usageType=http://adlnet.gov/expapi/attachments/code, display={en=binary attachment}, description=null, contentType=application/octet-stream, length=6, sha2=0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef, fileUrl=null, content=[64, 65, 66, 67, 68, 69])])], more=null)")); + + } } 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..c4604cf7 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.EqualsAndHashCode; import lombok.Value; +import lombok.With; /** * This class represents the xAPI Attachment object. @@ -83,6 +84,7 @@ public class Attachment { * The data of the attachment as byte array. */ @JsonIgnore + @With private byte[] content; // **Warning** do not add fields that are not required by the xAPI specification.