/**
* Licensed to Apereo under one or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information regarding copyright ownership. Apereo
* licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use
* this file except in compliance with the License. You may obtain a copy of the License at the
* following location:
*
* <p>http://www.apache.org/licenses/LICENSE-2.0
*
* <p>Unless required by applicable law or agreed to in writing, software distributed under the
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apereo.portal.events.tincan.providers;
import static java.lang.String.format;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.NameValuePair;
import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.message.BasicNameValuePair;
import org.apereo.portal.events.tincan.om.LrsStatement;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.PropertyResolver;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus.Series;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
/**
* HTTP provider that connects to a TinCan LRS service.
*
* <p>Each provider is must be configured with an id. The id will be used to load the configuration
* for the provider. The id must be injected as a spring property.*
*
* <p>Additional configuration is available by setting properties in the portal.properties or your
* local overrides.properties file. The additional properties that may be configured are:
*
* <table>
* <tr>
* <th>property</th>
* <th>required</th>
* <th>default value</th>
* <th>description</th>
* </tr>
* <tr>
* <td>org.apereo.portal.tincan-api.{ID}.url</td>
* <td>true</td>
* <td> </td>
* <td>The root of the LRS REST API.</td>
* </tr>
* <tr>
* <td>org.apereo.portal.tincan-api.{ID}.form-encode-activity-data</td>
* <td>false unless the LRS is LearningLocker</td>
* <td>false</td>
* <td>
* By default, the activities/state API accepts JSON in the POST body.
* LearningLocker requires that the content be form encoded instead.
* This setting converts the request to a multipart form.
*
* For LearningLocker, this should always be set to true.
* For ScormCloud, this should always be set to false or left to the default.
* Installations will need to experiment with other LRSs, but I believe
* that "false" more closely matches the spec
* </td>
* </tr>
* <tr>
* <td>org.apereo.portal.tincan-api.{ID}.activity-form-param-name</td>
* <td>false</td>
* <td>content</td>
* <td>
* If the "org.apereo.portal.tincan-api.{ID}.form-encode-activity-data"
* property is set, this property controls the property name to use
* when posting the form data.
* </td>
* </tr>
* <tr>
* <td>org.apereo.portal.tincan-api.{ID}.actor-name</td>
* <td>false</td>
* <td>uPortal</td>
* <td>
* The LRS will attempt to POST to the activities/state API on startup
* in order to test if the LRS is available. The activities/state API
* requires and agent element. This name is the name of the agent to
* use. Since this is only for testing connectivity, this is not
* critical and in most cases should be left as the default.
* </td>
* </tr>
* <tr>
* <td>org.apereo.portal.tincan-api.{ID}.actor-email</td>
* <td>false</td>
* <td>no-reply@jasig.org</td>
* <td>
* The email address of the agent making the initial activities request.
* Not critical and should be left as default in most cases.
* </td>
* </tr>
* <tr>
* <td>org.apereo.portal.tincan-api.{ID}.activityId</td>
* <td>false</td>
* <td>activityId</td>
* <td>
* The activity ID passed to the initial activities/state request.
* Since the initial request is just a connectivity test, can be
* left as the default in most cases.
* </td>
* </tr>
* <tr>
* <td>org.apereo.portal.tincan-api.{ID}.stateId</td>
* <td>false</td>
* <td>stateId</td>
* <td>
* The state ID passed to the initial activities/state request.
* Since the initial request is just a connectivity test, can be
* left as the default in most cases.
* </td>
* </tr>
* </table>
*
*/
public class DefaultTinCanAPIProvider implements ITinCanAPIProvider {
protected static final String STATEMENTS_REST_ENDPOINT = "/statements";
protected static final String STATES_REST_ENDPOINT = "/activities/state";
private static final String ACTOR_FORMAT =
"{\"name\":\"%s\",\"mbox\":\"mailto:%s\",\"objectType\":\"Agent\"}";
private static final String STATE_FORMAT = "{\"%s\":\"%s\"}";
private static final String STATE_KEY_STATUS = "status";
private static final String STATE_VALUE_STARTED = "started";
private static final String XAPI_VERSION_HEADER = "X-Experience-API-Version";
private static final String XAPI_VERSION_VALUE = "1.0.0";
private static final String PROPERTY_FORMAT = "org.apereo.portal.tincan-api.%s.%s";
private static final String PARAM_ACTIVITY_ID = "activityId";
private static final String PARAM_AGENT = "agent";
private static final String PARAM_STATE_ID = "stateId";
protected final Logger logger = LoggerFactory.getLogger(getClass());
private RestTemplate restTemplate;
private PropertyResolver propertyResolver;
private String LRSUrl = "";
private boolean enabled = false;
private String id = null;
private String activityId = "urn:tincan:uportal:activities:state:status";
private String stateId = "urn:tincan:uportal:activities:state:status:stateId";
private String actorEmail = "no-reply@jasig.org";
private String actorName = "uPortal";
private boolean formEncodeActivityData = false;
private String activitiesFormParamName = "content";
/**
* Set the rest template object.
*
* @param restTemplate the rest template object
*/
@Autowired
public void setRestTemplate(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
/**
* Property resolver used to read property values based on the provider id.
*
* @param propertyResolver the property resolver
*/
@Autowired
public void setPropertyResolver(PropertyResolver propertyResolver) {
this.propertyResolver = propertyResolver;
}
/**
* Set the id of the provider to use. The ID will be used to read the configuration for this
* provider.
*
* @param id the provider id.
*/
@Required
public void setId(String id) {
this.id = id;
}
/**
* If the xAPI interface is enabled or disabled. Defaults to "false"
*
* @param enabled the xAPI status
*/
@Value("${org.apereo.portal.tincan-api.enabled:false}")
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
/**
* Check if the LRS provider is enabled.
*
* @return true if the LRS provider is enable, else false
*/
protected boolean isEnabled() {
return enabled;
}
/**
* Get the base LRS URL.
*
* @return the base URL
*/
protected String getLRSUrl() {
return LRSUrl;
}
/**
* Initialize the API. Just sends an initialization event to the LRS provider. This uses the
* activities/state API to do the initial test.
*/
@Override
public void init() {
loadConfig();
if (!isEnabled()) {
return;
}
try {
String actorStr = format(ACTOR_FORMAT, actorName, actorEmail);
// Setup GET params...
List<BasicNameValuePair> getParams = new ArrayList<>();
getParams.add(new BasicNameValuePair(PARAM_ACTIVITY_ID, activityId));
getParams.add(new BasicNameValuePair(PARAM_AGENT, actorStr));
getParams.add(new BasicNameValuePair(PARAM_STATE_ID, stateId));
Object body = null;
if (formEncodeActivityData) {
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
String json = format(STATE_FORMAT, STATE_KEY_STATUS, STATE_VALUE_STARTED);
map.add(activitiesFormParamName, json);
body = map;
} else {
// just post a simple: {"status": "started"} record to the states API to verify
// the service is up.
Map<String, String> data = new HashMap<String, String>();
data.put(STATE_KEY_STATUS, STATE_VALUE_STARTED);
body = data;
}
ResponseEntity<Object> response =
sendRequest(
STATES_REST_ENDPOINT, HttpMethod.POST, getParams, body, Object.class);
if (response.getStatusCode().series() != Series.SUCCESSFUL) {
logger.error(
"LRS provider for URL "
+ LRSUrl
+ " it not configured properly, or is offline. Disabling provider.");
}
// todo: Need to think through a strategy for handling errors submitting
// to the LRS.
} catch (HttpClientErrorException e) {
// log some additional info in this case...
logger.error(
"LRS provider for URL "
+ LRSUrl
+ " failed to contact LRS for initialization. Disabling provider.",
e);
logger.error(
" Status: {}, Response: {}", e.getStatusCode(), e.getResponseBodyAsString());
enabled = false;
} catch (Exception e) {
logger.error(
"LRS provider for URL "
+ LRSUrl
+ " failed to contact LRS for initialization. Disabling provider",
e);
enabled = false;
}
}
/**
* Actually send an event to the provider.
*
* @param statement the LRS statement to send.
*/
@Override
public boolean sendEvent(LrsStatement statement) {
if (!isEnabled()) {
return false;
}
ResponseEntity<Object> response =
sendRequest(
STATEMENTS_REST_ENDPOINT, HttpMethod.POST, null, statement, Object.class);
if (response.getStatusCode().series() == Series.SUCCESSFUL) {
logger.trace("LRS provider successfully sent to {}, statement: {}", LRSUrl, statement);
} else {
logger.error("LRS provider failed to send to {}, statement: {}", LRSUrl, statement);
logger.error("- Response: {}", response);
return false;
}
return true;
}
@Override
public void destroy() {}
/**
* Read the LRS config.
*
* <p>"url" is the only required property. If not set, will disable this LRS provider.
*
* <p>Rather than asking installations to write a lot of XML, this pushes most of the
* configuration out to portal.properties. It reads dynamically named properties based on the
* "id" of this LRS provider. This is similar to the way that the TinCan configuration is
* handled for Sakai.
*/
protected void loadConfig() {
if (!isEnabled()) {
return;
}
final String urlProp = format(PROPERTY_FORMAT, id, "url");
LRSUrl = propertyResolver.getProperty(urlProp);
actorName =
propertyResolver.getProperty(format(PROPERTY_FORMAT, id, "actor-name"), actorName);
actorEmail =
propertyResolver.getProperty(
format(PROPERTY_FORMAT, id, "actor-email"), actorEmail);
activityId =
propertyResolver.getProperty(
format(PROPERTY_FORMAT, id, "activity-id"), activityId);
stateId = propertyResolver.getProperty(format(PROPERTY_FORMAT, id, "state-id"), stateId);
formEncodeActivityData =
propertyResolver.getProperty(
format(PROPERTY_FORMAT, id, "form-encode-activity-data"),
Boolean.class,
formEncodeActivityData);
activitiesFormParamName =
propertyResolver.getProperty(
format(PROPERTY_FORMAT, id, "activity-form-param-name"),
activitiesFormParamName);
if (StringUtils.isEmpty(LRSUrl)) {
logger.error("Disabling TinCan API interface. Property {} not set!", urlProp);
enabled = false;
return;
}
// strip trailing '/' if included
LRSUrl = LRSUrl.replaceAll("/*$", "");
}
/**
* Send a request to the LRS.
*
* @param pathFragment the URL. Should be relative to the xAPI API root
* @param method the HTTP method
* @param getParams the set of GET params
* @param postData the post data.
* @param returnType the type of object to expect in the response
* @param <T> The type of object to expect in the response
* @return The response object.
*/
protected <T> ResponseEntity<T> sendRequest(
String pathFragment,
HttpMethod method,
List<? extends NameValuePair> getParams,
Object postData,
Class<T> returnType) {
HttpHeaders headers = new HttpHeaders();
headers.add(XAPI_VERSION_HEADER, XAPI_VERSION_VALUE);
// make multipart data is handled correctly.
if (postData instanceof MultiValueMap) {
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
}
URI fullURI = buildRequestURI(pathFragment, getParams);
HttpEntity<?> entity = new HttpEntity<>(postData, headers);
ResponseEntity<T> response = restTemplate.exchange(fullURI, method, entity, returnType);
return response;
}
/**
* Build a URI for the REST request.
*
* <p>Note: this converts to URI instead of using a string because the activities/state API
* requires you to pass JSON as a GET parameter. The {...} confuses the RestTemplate path
* parameter handling. By converting to URI, I skip that.
*
* @param pathFragment The path fragment relative to the LRS REST base URL
* @param params The list of GET parameters to encode. May be null.
* @return The full URI to the LMS REST endpoint
*/
private URI buildRequestURI(String pathFragment, List<? extends NameValuePair> params) {
try {
String queryString = "";
if (params != null && !params.isEmpty()) {
queryString = "?" + URLEncodedUtils.format(params, "UTF-8");
}
URI fullURI = new URI(LRSUrl + pathFragment + queryString);
return fullURI;
} catch (URISyntaxException e) {
throw new RuntimeException("Error creating request URI", e);
}
}
}