Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
28bf7f6
add support for attachments
Selindek Mar 10, 2023
0fb221d
Merge branch 'main' into attachment_support
Selindek Mar 20, 2023
d553a00
Merge branch 'main' into attachment_support
Selindek Mar 20, 2023
9ef5ada
add comment to pom.xml
Selindek Mar 20, 2023
bf5644c
Correct formatting
Mar 20, 2023
1ecfc84
fcs
Selindek Mar 21, 2023
2ccfdb4
remove commons-codec dependency, refactor attachment content
Selindek Mar 21, 2023
720b827
Merge branch 'main' into attachment_support
thomasturrell Mar 21, 2023
87ab26d
add post-statement-with-attachment sample
Selindek Mar 22, 2023
20edfda
fix pom
Selindek Mar 22, 2023
de5e42c
hmmm
Selindek Mar 22, 2023
fabae0a
fixup
Selindek Mar 22, 2023
8604fc8
add jpg attachment to attachment sample
Selindek Mar 22, 2023
b58080f
Update samples/post-statement-with-attachment/src/main/java/dev/learn…
thomasturrell Mar 22, 2023
d2ffc5e
Apply suggestions from code review
thomasturrell Mar 22, 2023
3e768e5
Apply suggestions from code review
thomasturrell Mar 22, 2023
45cf059
Update samples/post-statement-with-attachment/src/main/java/dev/learn…
thomasturrell Mar 22, 2023
a5ea8a2
Merge remote-tracking branch 'origin/main' into attachment_support
Mar 22, 2023
f960a09
working but ugly solution
Selindek Mar 22, 2023
0ae82b8
construct multipart body directly into byte array
Selindek Mar 22, 2023
53ad9b3
fixup
Selindek Mar 22, 2023
ba00592
Merge branch 'attachment_support' of https://github.com/BerryCloud/xa…
Mar 22, 2023
49036b2
Add example
Mar 22, 2023
584c82f
Add null protection
Mar 23, 2023
95382ff
refactor MultipartHelper to a service
Selindek Mar 23, 2023
e404f2b
Merge branch 'attachment_support' of https://github.com/BerryCloud/xa…
Selindek Mar 23, 2023
6a84909
add tests
Mar 23, 2023
285f661
Merge branch 'attachment_support' of https://github.com/BerryCloud/xa…
Selindek Mar 23, 2023
5aa1c71
add test for object mapper
Mar 23, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,26 @@ client.postStatement(
.block();
```

### Posting a Statement with an attachment

Example:

```java
client.postStatement(
r -> r.statement(s -> s.actor(a -> a.name("A N Other").mbox("mailto:another@example.com"))

.verb(Verb.ATTEMPTED)

.activityObject(o -> o.id("https://example.com/activity/simplestatement")
.definition(d -> d.addName(Locale.ENGLISH, "Simple Statement")))

.addAttachment(a -> a.content("Simple attachment").length(17).contentType("text/plain")
.usageType(URI.create("https://example.com/attachments/simplestatement"))
.addDisplay(Locale.ENGLISH, "text attachment"))

)).block();
```

### Posting Statements

Example:
Expand Down
1 change: 1 addition & 0 deletions samples/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<!-- Statements Resource -->
<module>get-statement</module>
<module>post-statement</module>
<module>post-statement-with-attachment</module>
<module>get-statements</module>
<module>get-more-statements</module>
<module>get-voided-statement</module>
Expand Down
22 changes: 22 additions & 0 deletions samples/post-statement-with-attachment/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.learning.xapi.samples</groupId>
<artifactId>xapi-samples-build</artifactId>
<version>1.1.2-SNAPSHOT</version>
</parent>
<artifactId>post-statement-with-attachment</artifactId>
<name>Post xAPI Statement With Attachment Sample</name>
<description>Post xAPI Statement With Attachment</description>
<dependencies>
<dependency>
<groupId>dev.learning.xapi</groupId>
<artifactId>xapi-client</artifactId>
</dependency>
<dependency>
<groupId>dev.learning.xapi.samples</groupId>
<artifactId>core</artifactId>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright 2016-2023 Berry Cloud Ltd. All rights reserved.
*/

package dev.learning.xapi.samples.poststatement;

import dev.learning.xapi.client.XapiClient;
import dev.learning.xapi.model.Verb;
import java.net.URI;
import java.nio.file.Files;
import java.util.Locale;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.ResponseEntity;
import org.springframework.util.ResourceUtils;

/**
* Sample using xAPI client to post a statement with attachments.
*
* @author Thomas Turrell-Croft
* @author István Rátkai (Selindek)
*/
@SpringBootApplication
public class PostStatementWithAttachmentApplication implements CommandLineRunner {

/**
* Default xAPI client. Properties are picked automatically from application.properties.
*/
@Autowired
private XapiClient client;

public static void main(String[] args) {
SpringApplication.run(PostStatementWithAttachmentApplication.class, args).close();
}

@Override
public void run(String... args) throws Exception {

// Load jpg attachment from class-path
var data = Files.readAllBytes(ResourceUtils.getFile("classpath:example.jpg").toPath());

// Post a statement
ResponseEntity<
UUID> response =
client
.postStatement(r -> r.statement(
s -> s.actor(a -> a.name("A N Other").mbox("mailto:another@example.com"))

.verb(Verb.ATTEMPTED)

.activityObject(o -> o.id("https://example.com/activity/simplestatement")
.definition(d -> d.addName(Locale.ENGLISH, "Simple Statement")))

// Add simple text attachment
.addAttachment(a -> a.content("Simple attachment").length(17)
.contentType("text/plain")
.usageType(URI.create("https://example.com/attachments/greeting"))
.addDisplay(Locale.ENGLISH, "text attachment"))

// Add binary attachment
.addAttachment(a -> a.content(data).length(data.length)
.contentType("image/jpeg")
.usageType(URI.create("https://example.com/attachments/greeting"))
.addDisplay(Locale.ENGLISH, "JPEG attachment"))

)).block();

// If any attachment with actual data was added to any statement in a request, then it is sent
// as a multipart/mixed request automatically instead of the standard application/json format

// Print the statementId of the newly created statement to the console
System.out.println("StatementId " + response.getBody());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
xapi.client.username = admin
xapi.client.password = password
xapi.client.baseUrl = https://example.com/xapi/
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/*
* Copyright 2016-2023 Berry Cloud Ltd. All rights reserved.
*/

package dev.learning.xapi.client;

import com.fasterxml.jackson.databind.ObjectMapper;
import dev.learning.xapi.model.Attachment;
import dev.learning.xapi.model.Statement;
import dev.learning.xapi.model.SubStatement;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.FastByteArrayOutputStream;
import org.springframework.web.reactive.function.client.WebClient.RequestBodySpec;

/**
* Helper methods for creating multipart message from statements.
*
* @author István Rátkai (Selindek)
*/
@Slf4j
@RequiredArgsConstructor
public final class MultipartService {

private static final String MULTIPART_BOUNDARY = "xapi-learning-dev-boundary";
private static final String MULTIPART_CONTENT_TYPE = "multipart/mixed; boundary="
+ MULTIPART_BOUNDARY;
private static final String CRLF = "\r\n";
private static final String BOUNDARY_PREFIX = "--";
private static final String BODY_SEPARATOR = BOUNDARY_PREFIX + MULTIPART_BOUNDARY + CRLF;
private static final String BODY_FOOTER = BOUNDARY_PREFIX + MULTIPART_BOUNDARY + BOUNDARY_PREFIX;
private static final String CONTENT_TYPE = HttpHeaders.CONTENT_TYPE + ":";

private static final byte[] BA_APP_JSON_HEADER = (CONTENT_TYPE + MediaType.APPLICATION_JSON_VALUE
+ CRLF + CRLF).getBytes(StandardCharsets.UTF_8);
private static final byte[] BA_CRLF = CRLF.getBytes(StandardCharsets.UTF_8);
private static final byte[] BA_BODY_SEPARATOR = BODY_SEPARATOR.getBytes(StandardCharsets.UTF_8);
private static final byte[] BA_BODY_FOOTER = BODY_FOOTER.getBytes(StandardCharsets.UTF_8);
private static final byte[] BA_CONTENT_TYPE = CONTENT_TYPE.getBytes(StandardCharsets.UTF_8);
private static final byte[] BA_ENCODING_HEADER = ("Content-Transfer-Encoding:binary" + CRLF)
.getBytes(StandardCharsets.UTF_8);
private static final byte[] BA_X_API_HASH = "X-Experience-API-Hash:"
.getBytes(StandardCharsets.UTF_8);

public static final MediaType MULTIPART_MEDIATYPE = MediaType.valueOf(MULTIPART_CONTENT_TYPE);

private final ObjectMapper objectMapper;

/**
* <p>
* Add a Statement to the request.
* </p>
* This method adds the statement and its attachments if there are any to the request body. Also
* sets the content-type to multipart/mixed if needed.
*
* @param requestSpec a {@link RequestBodySpec} object.
* @param statement a {@link Statement} to add.
*/
public void addBody(RequestBodySpec requestSpec, Statement statement) {

addBody(requestSpec, statement, getRealAttachments(statement));

}

/**
* <p>
* Adds a List of {@link Statement}s to the request.
* </p>
* This method adds the statements and their attachments if there are any to the request body.
* Also sets the content-type to multipart/mixed if needed.
*
* @param requestSpec a {@link RequestBodySpec} object.
* @param statements list of {@link Statement}s to add.
*/
public void addBody(RequestBodySpec requestSpec, List<Statement> statements) {

addBody(requestSpec, statements, statements.stream().flatMap(this::getRealAttachments));

}

private void addBody(RequestBodySpec requestSpec, Object statements,
Stream<Attachment> attachments) {

final var attachmentsBody = writeAttachments(attachments);

if (attachmentsBody.length == 0) {
// add body directly, content-type is default application/json
requestSpec.bodyValue(statements);
} else {
// has at least one attachment with actual data -> set content-type
requestSpec.contentType(MULTIPART_MEDIATYPE);
// construct whole multipart body manually
requestSpec.bodyValue(createMultipartBody(statements, attachmentsBody));
}

}

/**
* Gets {@link Attachment}s of a {@link Statement} which has data property as a {@link Stream}.
*
* @param statement a {@link Statement} object
* @return {@link Attachment} of a {@link Statement} which has data property as a {@link Stream}.
*/
private Stream<Attachment> getRealAttachments(Statement statement) {

// handle the rare scenario when a sub-statement has an attachment
Stream<Attachment> stream = statement.getObject() instanceof final SubStatement substatement
&& substatement.getAttachments() != null ? substatement.getAttachments().stream()
: Stream.empty();

if (statement.getAttachments() != null) {
stream = Stream.concat(stream, statement.getAttachments().stream());
}

return stream.filter(a -> a.getContent() != null);
}

private byte[] createMultipartBody(Object statements, byte[] attachments) {

try (var stream = new FastByteArrayOutputStream()) {
// Multipart Boundary
stream.write(BA_BODY_SEPARATOR);

// Header of first part
stream.write(BA_APP_JSON_HEADER);

// Body of first part
stream.write(objectMapper.writeValueAsBytes(statements));
stream.write(BA_CRLF);

// Body of attachments
stream.write(attachments);

// Footer
stream.write(BA_BODY_FOOTER);

return stream.toByteArrayUnsafe();
} catch (final IOException e) {
log.error("Cannot create multipart body", e);
return new byte[] {};
}
}

/*
* Writes attachments to a byte array. If there are no attachments in the stream then returns an
* empty array.
*/
private static byte[] writeAttachments(Stream<Attachment> attachments) {

try (var stream = new FastByteArrayOutputStream()) {

// Write each sha2-identical attachments only once
attachments.collect(Collectors.toMap(Attachment::getSha2, v -> v, (k, v) -> v)).values()
.forEach(a -> {
try {
// Multipart Boundary
stream.write(BA_BODY_SEPARATOR);

// Multipart headers
stream.write(BA_CONTENT_TYPE);
stream.write(a.getContentType().getBytes(StandardCharsets.UTF_8));
stream.write(BA_CRLF);

stream.write(BA_ENCODING_HEADER);

stream.write(BA_X_API_HASH);
stream.write(a.getSha2().getBytes(StandardCharsets.UTF_8));
stream.write(BA_CRLF);
stream.write(BA_CRLF);

// Multipart body
stream.write(a.getContent());
stream.write(BA_CRLF);
} catch (final IOException e) {
log.error("Cannot create multipart body", e);
}

});

return stream.toByteArrayUnsafe();
}
}

}
Loading