Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.phony: install, ci, clean
.phony: install, ci, clean, ci-integration

clean:
mvn clean
Expand All @@ -8,3 +8,6 @@ install:

ci:
mvn test

ci-integration:
mvn -Dlrs.integration.tests=true test
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,32 @@ If you need to create your own ObjectMapper or prefer to use an existing one in

## LRS Client

Coming Soon...
A very basic LRS Client has been added to deal with statements only (not attachments). Other resources and attachments will be added in the future.

You must create an LRS object with host and prefix, and credentials in order to initialize a client. Here is a code sample of API usage:

```
//presume some statements using the model
List<Statement> stmts = new ArrayList<>(List.of(stmt1, stmt2));

LRS lrs = new LRS("https://lrs.yetanalytics.com/xapi/", "username", "password");
StatementClient client = new StatementClient(lrs);
List<UUID> resultIds = client.postStatements(stmts);
```
Note the format of the host. It includes the prefix path, but excludes resources like `/statements`. The trailing `/` is optional.

Current API methods include:

`List<UUID> postStatements(List<Statement> stmts)`
`List<UUID> postStatement(Statement stmt)`
`List<Statement> getStatements(StatementFilters filters)`
`List<Statement> getStatements()`

The client will batch at the size (optionally) provided to the LRS object. It will also handle retrieving the results from `more` links when the LRS paginates responses.

A StatementFilters object can optionally be given to the `getStatements` method to allow for all xAPI statement resource filter types (except attachment).

More methods will be added in future to support other resources and also attachments.

## xAPI Validation

Expand Down
41 changes: 37 additions & 4 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<groupId>com.yetanalytics</groupId>
<artifactId>xapi-tools</artifactId>
<packaging>jar</packaging>
<version>0.0.1</version>
<version>0.0.2</version>
<name>xAPI Tools</name>
<description>Java Serialization Model and Tools for xAPI Standard (IEEE 9274.1.1)</description>
<url>https://github.com/yetanalytics/java-xapi-tools</url>
Expand Down Expand Up @@ -63,9 +63,36 @@
<version>5.4.1</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.4.8-jre</version>
</dependency>
<!-- logging -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-core</artifactId>
<version>1.5.12</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.12</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.16</version>
</dependency>
<!-- testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.2.0</version>
<scope>test</scope>
</dependency>
<dependency>
Expand All @@ -74,6 +101,12 @@
<version>0.4.16</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.20.6</version>
<scope>test</scope>
</dependency>
</dependencies>
<profiles>
<profile>
Expand Down
95 changes: 95 additions & 0 deletions src/main/java/com/yetanalytics/xapi/client/LRS.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.yetanalytics.xapi.client;

import java.net.URI;

/**
* Object for holding LRS connection details for StatementClient.
*/
public class LRS {

/**
* Constructor to create an LRS object with specific connection params
*
* @param host Host for LRS. Should include path, e.g. 'http://lrs.yetanalytics.com/xapi'
* @param key Key for LRS credentials
* @param secret Secret for LRS credentials
* @param batchSize Optional post batch size, defaults to 50
*/
public LRS (String host, String key, String secret, Integer batchSize){

if(key == null || key.isEmpty())
throw new IllegalArgumentException(
"LRS auth key must be present.");
this.key = key;

if(key == null || key.isEmpty())
throw new IllegalArgumentException(
"LRS auth secret must be present.");
this.secret = secret;

//Host Validation
this.host = URI.create(host);
if (this.host.getPath() == null) {
throw new IllegalArgumentException(
"LRS host must have path prefix.");
} else if (!this.host.getPath().endsWith("/")) {
this.host = URI.create(host.concat("/"));
}

if(batchSize != null && batchSize > 0){
this.batchSize = batchSize;
}
}

/**
* Constructor to create an LRS object with specific connection params
*
* @param host Host for LRS. Should include path, e.g. 'http://lrs.yetanalytics.com/xapi'
* @param key Key for LRS credentials
* @param secret Secret for LRS credentials
*/
public LRS (String host, String key, String secret){
this(host, key, secret, null);
}

private URI host;

private String key;

private String secret;

private Integer batchSize = 50;

public URI getHost() {
return host;
}

public void setHost(URI host) {
this.host = host;
}

public String getKey() {
return key;
}

public void setKey(String key) {
this.key = key;
}

public String getSecret() {
return secret;
}

public void setSecret(String secret) {
this.secret = secret;
}

public Integer getBatchSize() {
return batchSize;
}

public void setBatchSize(Integer batchSize) {
this.batchSize = batchSize;
}

}
193 changes: 193 additions & 0 deletions src/main/java/com/yetanalytics/xapi/client/StatementClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package com.yetanalytics.xapi.client;

import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.UUID;

import org.apache.http.Header;
import org.apache.http.HttpHeaders;
import org.apache.http.ParseException;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.message.BasicHeader;
import org.apache.http.util.EntityUtils;

import com.fasterxml.jackson.core.type.TypeReference;
import com.google.common.collect.Lists;
import com.yetanalytics.xapi.client.filters.StatementFilters;
import com.yetanalytics.xapi.exception.StatementClientException;
import com.yetanalytics.xapi.model.Statement;
import com.yetanalytics.xapi.model.StatementResult;
import com.yetanalytics.xapi.util.Mapper;

/**
* Minimal xAPI Client featuring GET and POST Operations for LRS interop.
*/
public class StatementClient {

private static final String STATEMENT_ENDPOINT = "statements";

private LRS lrs;
private CloseableHttpClient client;

/**
* Constructor to create an xAPI Client
*
* @param lrs The Learning Record store to connect to
*/
public StatementClient (LRS lrs) {
this.lrs = lrs;

String encodedCreds = Base64.getEncoder().encodeToString(
String.format("%s:%s", lrs.getKey(), lrs.getSecret()).getBytes());

//TODO: Version headers
List<Header> headers = new ArrayList<Header>();
headers.add(new BasicHeader("X-Experience-API-Version","1.0.3"));
headers.add(new BasicHeader(HttpHeaders.CONTENT_TYPE, "application/json"));
headers.add(new BasicHeader(HttpHeaders.ACCEPT, "application/json"));
headers.add(new BasicHeader("Authorization",
String.format("Basic %s", encodedCreds)));

this.client = HttpClients.custom()
.setDefaultHeaders(headers)
.build();
}

private List<UUID> doPost(List<Statement> statements, URI endpoint)
throws ParseException, IOException {
HttpPost post = new HttpPost(endpoint);
post.setEntity(new StringEntity(
Mapper.getMapper().writeValueAsString(statements)));

CloseableHttpResponse response = client.execute(post);

if (response.getStatusLine().getStatusCode() == 200) {
String responseBody = EntityUtils.toString(response.getEntity());
return Mapper.getMapper().readValue(
responseBody,
new TypeReference<List<UUID>>(){});
} else {
throw new StatementClientException(String.format(
"Error, Non-200 Status. Received: %s",
response.getStatusLine().getStatusCode()));
}
}

/**
* Method to post a single xAPI Statement to an LRS.
*
* @param stmt Statement to post to LRS
* @return List of IDs for created statement(s) from LRS
*/
public List<UUID> postStatement(Statement stmt) {
return postStatements(new ArrayList<>(List.of(stmt)));
}

/**
* Method to post a List of xAPI Statements to an LRS.
*
* @param stmts Statements to post to LRS
* @return List of IDs for created statement(s) from LRS
*/
public List<UUID> postStatements(List<Statement> stmts) {
try {
List<UUID> result = new ArrayList<UUID>();
for (List<Statement> p : Lists.partition(stmts, lrs.getBatchSize())) {
result.addAll(doPost(p, lrs.getHost().resolve(STATEMENT_ENDPOINT)));
}
return result;
} catch (ParseException | IOException e) {
throw new StatementClientException("Error posting Statements", e);
}
}

private StatementResult doGetStatementResult(URI endpoint)
throws ClientProtocolException, IOException {
HttpGet get = new HttpGet(endpoint);
CloseableHttpResponse response = client.execute(get);

if (response.getStatusLine().getStatusCode() == 200) {
String responseBody = EntityUtils.toString(response.getEntity());
return Mapper.getMapper().readValue(responseBody, StatementResult.class);
} else {
throw new StatementClientException(String.format(
"Error, Non-200 Status. Received: %s",
response.getStatusLine().getStatusCode()));
}
}

private Statement doGetStatement(URI endpoint)
throws ClientProtocolException, IOException {
HttpGet get = new HttpGet(endpoint);
CloseableHttpResponse response = client.execute(get);

if (response.getStatusLine().getStatusCode() == 200) {
String responseBody = EntityUtils.toString(response.getEntity());
return Mapper.getMapper().readValue(responseBody, Statement.class);
} else {
throw new StatementClientException(String.format(
"Error, Non-200 Status. Received: %s",
response.getStatusLine().getStatusCode()));
}
}

private URI resolveMore(URI moreLink) {
if (moreLink == null || moreLink.toString().equals(""))
return null;
URI uri = lrs.getHost().resolve(STATEMENT_ENDPOINT);
return uri.resolve(moreLink.toString());
}

/**
* Method to get Statements from LRS
*
* @param filters StatementFilters object to filter the query.
* @return All statements that match filter
*/
public List<Statement> getStatements(StatementFilters filters) {
List<Statement> statements = new ArrayList<Statement>();

URI target = lrs.getHost().resolve(STATEMENT_ENDPOINT);
if (filters != null) {
target = filters.addQueryToUri(target);
}

try {
while(target != null) {
if (filters != null &&
(filters.getStatementId() != null
|| filters.getVoidedStatementId() != null)) {
statements.add(doGetStatement(target));
target = null;
} else {
StatementResult result = doGetStatementResult(target);
statements.addAll(result.getStatements());
target = resolveMore(result.getMore());
}
}

} catch (IOException e) {
throw new StatementClientException("Error getting Statements", e);
}
return statements;
}

/**
* Method to get Statements from LRS with no filters
*
* @return All statements
*/
public List<Statement> getStatements() {
return getStatements(null);
}

}
Loading