├── .gitignore ├── LICENSE_HEADER ├── impl ├── LICENSE_HEADER ├── src │ ├── main │ │ └── java │ │ │ └── org │ │ │ └── sakaiproject │ │ │ └── lrs │ │ │ └── expapi │ │ │ ├── model │ │ │ ├── NonNullValueHashMap.java │ │ │ └── LRSKeys.java │ │ │ ├── util │ │ │ └── StatementMapUtils.java │ │ │ └── impl │ │ │ └── TincanapiLearningResourceStoreProvider.java │ └── webapp │ │ └── WEB-INF │ │ └── components.xml └── pom.xml ├── .travis.yml ├── README └── pom.xml /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | *.iml 3 | .idea 4 | -------------------------------------------------------------------------------- /LICENSE_HEADER: -------------------------------------------------------------------------------- 1 | Copyright ${year} ${holder} Licensed under the 2 | Educational Community License, Version 2.0 (the "License"); you may 3 | not use this file except in compliance with the License. You may 4 | obtain a copy of the License at 5 | 6 | http://www.osedu.org/licenses/ECL-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, 9 | software distributed under the License is distributed on an "AS IS" 10 | BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | or implied. See the License for the specific language governing 12 | permissions and limitations under the License. 13 | -------------------------------------------------------------------------------- /impl/LICENSE_HEADER: -------------------------------------------------------------------------------- 1 | Copyright ${year} ${holder} Licensed under the 2 | Educational Community License, Version 2.0 (the "License"); you may 3 | not use this file except in compliance with the License. You may 4 | obtain a copy of the License at 5 | 6 | http://www.osedu.org/licenses/ECL-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, 9 | software distributed under the License is distributed on an "AS IS" 10 | BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | or implied. See the License for the specific language governing 12 | permissions and limitations under the License. 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # travis-ci currently has Maven 3.2 which doesn't read our .mvn folder 2 | # and https://github.com/travis-ci/travis-ci/issues/4613 means we can't set MAVEN_OPTS directly 3 | before_install: 4 | # To get some build progress you could have -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.event.ExecutionEventLogger=info 5 | # but this results in more than 10,000 lines of build output 6 | - echo "MAVEN_OPTS='-Xms168m -Xmx1536m -XX:NewSize=64m -Djava.awt.headless=true -Dorg.slf4j.simpleLogger.defaultLogLevel=warn'" > ~/.mavenrc 7 | # https://github.com/travis-ci/travis-ci/issues/4629 8 | # This stops lots of warning about the broken nexus.codehaus.org repository 9 | - rm ~/.m2/settings.xml 10 | 11 | language: java 12 | jdk: 13 | - oraclejdk8 14 | sudo: false 15 | cache: 16 | directories: 17 | - $HOME/.m2 18 | 19 | # We don't want to cache our own artifacts, just the externally downloaded ones. 20 | # This makes the cache smaller and helps highlight problems with a clean build 21 | before_cache: 22 | - find $HOME/.m2/repository/org/sakaiproject -name \*-SNAPSHOT.\* -delete 23 | 24 | install: 25 | - mvn install -Psnapshots -DskipTests=true -Dmaven.javadoc.skip=true -V -B 26 | -------------------------------------------------------------------------------- /impl/src/main/java/org/sakaiproject/lrs/expapi/model/NonNullValueHashMap.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013 Unicon (R) Licensed under the 3 | * Educational Community License, Version 2.0 (the "License"); you may 4 | * not use this file except in compliance with the License. You may 5 | * obtain a copy of the License at 6 | * 7 | * http://www.osedu.org/licenses/ECL-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, 10 | * software distributed under the License is distributed on an "AS IS" 11 | * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 12 | * or implied. See the License for the specific language governing 13 | * permissions and limitations under the License. 14 | */ 15 | package org.sakaiproject.lrs.expapi.model; 16 | 17 | import java.util.HashMap; 18 | 19 | /** 20 | * @param 21 | * @param 22 | * 23 | * @author Robert Long (rlong @ unicon.net) 24 | */ 25 | public class NonNullValueHashMap extends HashMap { 26 | private static final long serialVersionUID = 1L; 27 | 28 | /** 29 | * If the key or value is null, we don't add it to the map. This allows us to "quietly" ignore storing nulls 30 | * 31 | * @see java.util.HashMap#put(java.lang.Object, java.lang.Object) 32 | */ 33 | @Override 34 | public V put(K key, V value) { 35 | return (null == key || null == value) ? null : super.put(key, value); 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /impl/src/webapp/WEB-INF/components.xml: -------------------------------------------------------------------------------- 1 | 2 | 18 | 22 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /impl/src/main/java/org/sakaiproject/lrs/expapi/model/LRSKeys.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013 Unicon (R) Licensed under the 3 | * Educational Community License, Version 2.0 (the "License"); you may 4 | * not use this file except in compliance with the License. You may 5 | * obtain a copy of the License at 6 | * 7 | * http://www.osedu.org/licenses/ECL-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, 10 | * software distributed under the License is distributed on an "AS IS" 11 | * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 12 | * or implied. See the License for the specific language governing 13 | * permissions and limitations under the License. 14 | */ 15 | package org.sakaiproject.lrs.expapi.model; 16 | 17 | /** 18 | * Standard LRS Statement keys 19 | * 20 | * @author Robert Long (rlong @ unicon.net) 21 | * 22 | */ 23 | public interface LRSKeys { 24 | 25 | static final String INVERSE_FUNCTIONAL_IDENTIFIER_PROPERTY = "lrs.tincanapi.inverse.functional.identifier"; 26 | 27 | public static enum LRSStatementKey { 28 | actor, context, object, result, stored, timestamp, verb; 29 | } 30 | 31 | public static enum LRSActorKey { 32 | mbox, name, objectType, openid, account, homePage 33 | } 34 | 35 | public static enum LRSContextKey { 36 | contextActivities, instructor, revision 37 | } 38 | 39 | public static enum LRSVerbKey { 40 | id, display 41 | } 42 | 43 | public static enum LRSObjectKey { 44 | id, objectType, definition 45 | } 46 | 47 | public static enum LRSDefinitionKey { 48 | name, type, description 49 | } 50 | 51 | public static enum LRSScoreKey { 52 | max, min, raw, scaled 53 | } 54 | 55 | public static enum LRSResultKey { 56 | completion, duration, score, extensions, success, response 57 | } 58 | 59 | public static enum LRSIdentifierKey { 60 | mbox, mbox_sha1sum, openid, account 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /impl/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | tincanapi-provider-base 7 | org.sakaiproject 8 | 21-SNAPSHOT 9 | ../pom.xml 10 | 11 | Sakai tincanapi LRS Provider Impl 12 | tincanapi-provider-impl 13 | 14 | Unicon 15 | http://unicon.net/ 16 | 17 | 2013 18 | sakai-component 19 | 20 | 21 | components 22 | 23 | 24 | 25 | 26 | 27 | org.sakaiproject.kernel 28 | sakai-kernel-api 29 | 30 | 31 | org.sakaiproject.kernel 32 | sakai-component-manager 33 | 34 | 35 | org.sakaiproject.kernel 36 | sakai-kernel-util 37 | 38 | 39 | 40 | org.sakaiproject.entitybroker 41 | entitybroker-api 42 | 43 | 44 | org.sakaiproject.entitybroker 45 | entitybroker-utils 46 | 47 | 48 | 49 | org.sakaiproject.basiclti 50 | basiclti-util 51 | ${project.version} 52 | 53 | 54 | 55 | org.slf4j 56 | slf4j-api 57 | provided 58 | 59 | 60 | 61 | org.apache.commons 62 | commons-lang3 63 | 64 | 65 | commons-codec 66 | commons-codec 67 | 68 | 69 | org.apache.httpcomponents 70 | httpclient 71 | 72 | 73 | commons-validator 74 | commons-validator 75 | 76 | 77 | joda-time 78 | joda-time 79 | ${joda.time.version} 80 | 81 | 82 | javax.servlet 83 | javax.servlet-api 84 | provided 85 | 86 | 87 | 88 | 89 | junit 90 | junit 91 | test 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | This is the Sakai tincanapi LRS provider. 2 | Developed by Aaron Zeckoski (Lead), Robert Long, and Charles Hasegawa of Unicon (http://www.unicon.net/) for Universetiet Van Amsterdam (http://www.uva.nl/) 3 | 4 | ======= 5 | Summary 6 | ======= 7 | This is an implementation of the /kernel/api/src/main/java/org/sakaiproject/event/api/LearningResourceStoreProvider.java. 8 | 9 | Provides support for Sakai to work with Learning Record Stores (LRS) 10 | Allows centralized registration of LRS activity statements which Sakai 11 | will then route over to the configured LRS system (via the Experience API (XAPI)). 12 | See https://jira.sakaiproject.org/browse/KNL-1042 13 | 14 | http://en.wikipedia.org/wiki/Learning_Record_Store 15 | A Learning Record Store (LRS) is a data store that serve as a repository for learning records 16 | necessary for using the Experience API (XAPI). The Experience API (XAPI) is also known as "next-gen SCORM" 17 | or previously the TinCanAPI. The concept of the LRS was introduced to the e-learning industry in 2011, 18 | and is a shift to the way e-learning specifications function. 19 | 20 | ============= 21 | CONFIGURATION 22 | ============= 23 | Add the following to your Sakai config properties file 24 | 25 | # Enable LRS processing 26 | # Default: false 27 | lrs.enabled=true 28 | 29 | # Enable statement origin filters (cause certain statements to be skipped based on their origin) 30 | # NOTE: most origins are the names of the tools. e.g. assignments, announcement, calendar, chat, content, gradebook, lessonbuilder, news, podcast, syllabus, webcontent, rwiki 31 | # Default: No filters (all statements processed) 32 | #lrs.origins.filter=tool1,tool2,tool3 33 | 34 | ## TinCanAPI specific config settings 35 | # URL to the tincan server 36 | # Default: https://cloud.scorm.com/ScormEngineInterface/TCAPI/50ZLHZXM0Q/statements (this is a test account) 37 | lrs.tincanapi.url=https://url/to/your/tincan/server/api/path 38 | # Timeout for requests to the tincan server (in ms) 39 | # Default: 5000 (5 seconds) 40 | #lrs.tincanapi.request.timeout=10000 41 | ## LRS Authentication 42 | # This will use OAuth if configured OR Basic Auth if OAuth is not setup, 43 | # the Auth config is required so if these are both blank then the provider will fail to startup 44 | # Basic Auth header value: base64(username + ":" + password) 45 | # Default: 50ZLHZXM0Q:crCPCRQCoqiQN9rkliIJlLiVzk0CjsuDc52mik29 (matches the default URL above) 46 | lrs.tincanapi.basicAuthUserPass=UUUUUU:PPPPPP 47 | # OAuth settings (no defaults) 48 | # OAuth shared (consumer) key 49 | #lrs.tincanapi.consumer.key=XXXXXX 50 | # OAuth secret 51 | #lrs.tincanapi.consumer.secret=YYYYY 52 | # OAuth realm 53 | #lrs.tincanapi.realm=ZZZZZ 54 | 55 | ## LRS Settings 56 | # Inverse functional identifier type 57 | # Valid types: account, mbox, mbox_sha1sum, openid* (* = NOT IMPLEMENTED) 58 | # DEFAULT: account ("account":{"name":"USER_EID", "homePage":"SAKAI_URL"}) 59 | # Value MUST BE unique, as LRS only allow a single identifier in statement actor data 60 | #lrs.tincanapi.inverse.functional.identifier=account 61 | 62 | ============ 63 | INSTALLATION 64 | ============ 65 | Requires version 10 of Sakai or better. 66 | 67 | Download or checkout the Unicon TinCanAPI provider (this README is part of it). 68 | https://github.com/Apereo-Learning-Analytics-Initiative/SakaiXAPI-Provider 69 | 70 | Switch to the branch you are running (10.x, 11.x, etc.) 71 | 72 | Build the code using maven 2 or 3: 73 | mvn clean install sakai:deploy 74 | 75 | Add the config (as shown in the config section above). 76 | 77 | Restart the Sakai server and check the logs. You should see some LRS INFO logs 78 | about a successful init OR error messages that explain the problem. 79 | 80 | --------------------- 81 | NOTES 82 | --------------------- 83 | There are Kernel patches you will need to deploy: 84 | https://jira.sakaiproject.org/browse/KNL-1429 85 | https://jira.sakaiproject.org/browse/KNL-1446 86 | 87 | --------------------- 88 | NOTES for 2.9.x users 89 | --------------------- 90 | There are patches in the following tickets that you will need to apply to Sakai 2.9: 91 | https://jira.sakaiproject.org/browse/KNL-1043 92 | https://jira.sakaiproject.org/browse/KNL-1045 93 | https://jira.sakaiproject.org/browse/SAK-23566 94 | 95 | -Robert Long (rlong @ unicon.net) 96 | -Aaron Zeckoski (azeckoski @ unicon.net) (azeckoski @ vt.edu) 97 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | Sakai tincanapi LRS Provider 6 | tincanapi-provider-base 7 | pom 8 | 9 | 10 | 11 | master 12 | org.sakaiproject 13 | 21-SNAPSHOT 14 | ../master/pom.xml 15 | 16 | 17 | 18 | ${maven.build.timestamp} 19 | yyyyMMdd 20 | 21 | 22 | 2013 23 | 24 | 25 | impl 26 | 27 | 28 | 29 | 30 | 31 | org.sakaiproject 32 | tincanapi-provider-impl 33 | 21-SNAPSHOT 34 | 35 | 36 | 37 | 38 | 39 | 40 | src/main/java 41 | 42 | 43 | ${basedir}/src/main/java 44 | 45 | **/*.xml 46 | 47 | 48 | 49 | ${basedir}/src/main/resources 50 | true 51 | 52 | **/* 53 | 54 | 55 | 56 | src/test/java 57 | 58 | 59 | ${basedir}/src/main/webapp/WEB-INF 60 | 61 | *.xml 62 | 63 | 64 | 65 | ${basedir}/src/test/resources 66 | 67 | **/* 68 | 69 | 70 | 71 | 72 | 73 | org.apache.maven.plugins 74 | maven-compiler-plugin 75 | 3.5.1 76 | 77 | ${sakai.jdk.version} 78 | ${sakai.jdk.version} 79 | 80 | 81 | 82 | 83 | org.apache.maven.plugins 84 | maven-war-plugin 85 | 86 | ${basedir}/src/main/webapp 87 | ${project.build.directory} 88 | 89 | 90 | 91 | 93 | com.mycila.maven-license-plugin 94 | maven-license-plugin 95 | 1.9.0 96 | 97 | true 98 |
${basedir}/LICENSE_HEADER
99 | 100 | ${project.name} 101 | ${project.inceptionYear} 102 | Unicon (R) 103 | 104 | 105 | target/** 106 | m2-target/** 107 | **/*.properties 108 | **/*.txt 109 | LICENSE* 110 | **/README 111 | bin/** 112 | .idea/** 113 | **/*.iml 114 | 115 | 116 | DYNASCRIPT_STYLE 117 | 118 | UTF-8 119 |
120 | 121 | 122 | 123 | check 124 | 125 | 126 | 127 |
128 |
129 |
130 | 131 | 132 | 133 | 134 | sakai-maven 135 | Sakai Maven Repo 136 | default 137 | http://source.sakaiproject.org/maven2 138 | 139 | true 140 | 141 | 142 | 143 | 144 | 145 | 146 | ECL 2.0 147 | repo 148 | http://www.osedu.org/licenses/ECL-2.0/ 149 | 150 | 151 | 152 | 153 | scm:svn:https://source.sakaiproject.org/svn/msub/unicon.net/tincanapi-provider/trunk 154 | scm:svn:https://source.sakaiproject.org/svn/msub/unicon.net/tincanapi-provider/trunk 155 | https://source.sakaiproject.org/viewsvn/msub/unicon.net/tincanapi-provider/trunk 156 | 157 | 158 | 159 | 160 | Sakai-Maven2 161 | Sakaiproject Maven 2 repository 162 | dav:https://source.sakaiproject.org/maven2 163 | 164 | 165 | local site 166 | file:/tmp/tincanapi/site/ 167 | 168 | 169 | 170 | 171 | 172 | aaronz@vt.edu 173 | Aaron Zeckoski 174 | azeckoski@vt.edu 175 | http://tinyurl.com/azprofile 176 | 177 | Architect 178 | Developer 179 | 180 | -5 181 | 182 | 183 | rlong@unicon.net 184 | Robert Long 185 | rlong@unicon.net 186 | 187 | Engineer 188 | 189 | -7 190 | 191 | 192 | 193 |
194 | -------------------------------------------------------------------------------- /impl/src/main/java/org/sakaiproject/lrs/expapi/util/StatementMapUtils.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013 Unicon (R) Licensed under the 3 | * Educational Community License, Version 2.0 (the "License"); you may 4 | * not use this file except in compliance with the License. You may 5 | * obtain a copy of the License at 6 | * 7 | * http://www.osedu.org/licenses/ECL-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, 10 | * software distributed under the License is distributed on an "AS IS" 11 | * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 12 | * or implied. See the License for the specific language governing 13 | * permissions and limitations under the License. 14 | */ 15 | package org.sakaiproject.lrs.expapi.util; 16 | 17 | import java.util.HashMap; 18 | import java.util.Map; 19 | 20 | import org.apache.commons.codec.digest.DigestUtils; 21 | import org.apache.commons.lang3.StringUtils; 22 | import org.sakaiproject.component.api.ServerConfigurationService; 23 | import org.sakaiproject.component.cover.ComponentManager; 24 | import org.sakaiproject.event.api.LearningResourceStoreService.LRS_Actor; 25 | import org.sakaiproject.event.api.LearningResourceStoreService.LRS_Context; 26 | import org.sakaiproject.event.api.LearningResourceStoreService.LRS_Object; 27 | import org.sakaiproject.event.api.LearningResourceStoreService.LRS_Result; 28 | import org.sakaiproject.event.api.LearningResourceStoreService.LRS_Verb; 29 | import org.sakaiproject.lrs.expapi.model.LRSKeys; 30 | import org.sakaiproject.lrs.expapi.model.NonNullValueHashMap; 31 | 32 | /** 33 | * Utilities to process LRS Statement objects 34 | * 35 | * @author Robert Long (rlong @ unicon.net) 36 | */ 37 | public class StatementMapUtils implements LRSKeys { 38 | 39 | /** 40 | * @return a map of the actor values 41 | */ 42 | public static Map getActorMap(LRS_Actor actor) { 43 | HashMap actorMap = new NonNullValueHashMap<>(); 44 | 45 | actorMap.put(LRSActorKey.name.toString(), actor.getName()); 46 | actorMap.put(LRSActorKey.objectType.toString(), actor.getObjectType()); 47 | 48 | ServerConfigurationService serverConfigurationService = (ServerConfigurationService) ComponentManager.get(ServerConfigurationService.class); 49 | String identifier = serverConfigurationService.getString(INVERSE_FUNCTIONAL_IDENTIFIER_PROPERTY, LRSIdentifierKey.account.toString()); 50 | 51 | if (StringUtils.equalsIgnoreCase(identifier, LRSIdentifierKey.mbox.toString())) { 52 | actorMap.put(LRSIdentifierKey.mbox.toString(), actor.getMbox()); 53 | } else if (StringUtils.equalsIgnoreCase(identifier, LRSIdentifierKey.mbox_sha1sum.toString())) { 54 | actorMap.put(LRSIdentifierKey.mbox_sha1sum.toString(), DigestUtils.sha1Hex(actor.getMbox())); 55 | } else if (StringUtils.equalsIgnoreCase(identifier, LRSIdentifierKey.openid.toString())) { 56 | actorMap.put(LRSActorKey.openid.toString(), actor.getOpenid()); 57 | } else { 58 | // default to "account" 59 | HashMap accountMap = new NonNullValueHashMap(); 60 | String name = actor.getAccount().getName(); 61 | if (StringUtils.isBlank(name)) { 62 | name = "unknown"; 63 | } 64 | accountMap.put(LRSActorKey.name.toString(), name); 65 | 66 | String homePage = actor.getAccount().getHomePage(); 67 | if (StringUtils.isBlank(homePage)) { 68 | homePage = serverConfigurationService.getServerUrl(); 69 | } 70 | accountMap.put(LRSActorKey.homePage.toString(), homePage); 71 | actorMap.put(LRSActorKey.account.toString(), accountMap); 72 | } 73 | 74 | return actorMap; 75 | } 76 | 77 | /** 78 | * @return a map of the values from the context 79 | */ 80 | public static Map getContextMap(LRS_Context context) { 81 | HashMap contextMap = new NonNullValueHashMap<>(); 82 | contextMap.put(LRSContextKey.contextActivities.toString(), context.getActivitiesMap()); 83 | 84 | // Instructor optional 85 | if (null != context.getInstructor()) { 86 | contextMap.put(LRSContextKey.instructor.toString(), getActorMap(context.getInstructor())); 87 | } 88 | 89 | // Revision optional 90 | if (null != context.getRevision()) { 91 | contextMap.put(LRSContextKey.revision.toString(), context.getRevision()); 92 | } 93 | 94 | return contextMap; 95 | } 96 | 97 | /** 98 | * @return a map of the values from the LRS object 99 | */ 100 | public static Map getObjectMap(LRS_Object lrsObject) { 101 | HashMap definitionMap = new NonNullValueHashMap<>(); 102 | definitionMap.put(LRSDefinitionKey.name.toString(), lrsObject.getActivityName()); 103 | definitionMap.put(LRSDefinitionKey.type.toString(), lrsObject.getActivityType()); 104 | definitionMap.put(LRSDefinitionKey.description.toString(), lrsObject.getDescription()); 105 | 106 | HashMap objectMap = new NonNullValueHashMap<>(); 107 | objectMap.put(LRSObjectKey.id.toString(), lrsObject.getId()); 108 | objectMap.put(LRSObjectKey.objectType.toString(), "Activity"); 109 | objectMap.put(LRSObjectKey.definition.toString(), definitionMap); 110 | 111 | return objectMap; 112 | } 113 | 114 | /** 115 | * @return a map of the values from the LRS result 116 | */ 117 | public static Map getResultMap(LRS_Result result) { 118 | HashMap resultMap = new NonNullValueHashMap<>(); 119 | resultMap.put(LRSResultKey.completion.toString(), result.getCompletion()); 120 | 121 | // Duration has to be formatted to https://en.wikipedia.org/wiki/ISO_8601#Durations 122 | if (result.getDuration() > 0) { 123 | resultMap.put(LRSResultKey.duration.toString(), "PT" + result.getDuration() + "S"); 124 | } 125 | 126 | // Grade should only be set if there is no numeric value set 127 | if (StringUtils.isEmpty(result.getGrade())) { 128 | HashMap scoreMap = new NonNullValueHashMap<>(); 129 | scoreMap.put(LRSScoreKey.max.toString(), result.getMax()); 130 | scoreMap.put(LRSScoreKey.min.toString(), result.getMin()); 131 | scoreMap.put(LRSScoreKey.raw.toString(), result.getRaw()); 132 | scoreMap.put(LRSScoreKey.scaled.toString(), result.getScaled()); 133 | 134 | resultMap.put(LRSResultKey.score.toString(), scoreMap); 135 | } else { 136 | HashMap name = new NonNullValueHashMap<>(); 137 | name.put("en-US", result.getGrade()); 138 | 139 | HashMap definition = new NonNullValueHashMap<>(); 140 | definition.put(LRSDefinitionKey.type.toString(), "http://sakaiproject.org/xapi/activitytypes/grade_classification"); 141 | definition.put(LRSDefinitionKey.name.toString(), name); 142 | 143 | HashMap classification = new NonNullValueHashMap<>(); 144 | classification.put(LRSObjectKey.objectType.toString(), "activity"); 145 | classification.put(LRSObjectKey.id.toString(), "http://sakaiproject.org/xapi/activities/" + result.getGrade()); 146 | classification.put(LRSObjectKey.definition.toString(), definition); 147 | 148 | HashMap extensions = new NonNullValueHashMap<>(); 149 | extensions.put("http://sakaiproject.org/xapi/extensions/result/classification", classification); 150 | 151 | resultMap.put(LRSResultKey.extensions.toString(), extensions); 152 | } 153 | 154 | resultMap.put(LRSResultKey.success.toString(), result.getSuccess()); 155 | resultMap.put(LRSResultKey.response.toString(), result.getResponse()); 156 | 157 | return resultMap; 158 | } 159 | 160 | /** 161 | * @return a map of values from the LRS verb 162 | */ 163 | public static Map getVerbMap(LRS_Verb verb) { 164 | HashMap result = new NonNullValueHashMap<>(); 165 | result.put(LRSVerbKey.id.toString(), verb.getId()); 166 | result.put(LRSVerbKey.display.toString(), verb.getDisplay()); 167 | 168 | return result; 169 | } 170 | 171 | } 172 | -------------------------------------------------------------------------------- /impl/src/main/java/org/sakaiproject/lrs/expapi/impl/TincanapiLearningResourceStoreProvider.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013 Unicon (R) Licensed under the 3 | * Educational Community License, Version 2.0 (the "License"); you may 4 | * not use this file except in compliance with the License. You may 5 | * obtain a copy of the License at 6 | * 7 | * http://www.osedu.org/licenses/ECL-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, 10 | * software distributed under the License is distributed on an "AS IS" 11 | * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 12 | * or implied. See the License for the specific language governing 13 | * permissions and limitations under the License. 14 | */ 15 | 16 | package org.sakaiproject.lrs.expapi.impl; 17 | 18 | import java.io.IOException; 19 | import java.net.URISyntaxException; 20 | import java.util.HashMap; 21 | 22 | import net.oauth.OAuthAccessor; 23 | import net.oauth.OAuthConsumer; 24 | import net.oauth.OAuthException; 25 | import net.oauth.OAuthMessage; 26 | import net.oauth.OAuthServiceProvider; 27 | 28 | import org.apache.commons.codec.binary.Base64; 29 | import org.apache.commons.lang3.StringUtils; 30 | import org.apache.commons.lang3.time.DateFormatUtils; 31 | import org.apache.commons.lang3.time.FastDateFormat; 32 | import org.apache.commons.validator.routines.UrlValidator; 33 | import org.azeckoski.reflectutils.transcoders.JSONTranscoder; 34 | import org.sakaiproject.component.api.ServerConfigurationService; 35 | import org.sakaiproject.entitybroker.util.http.HttpClientWrapper; 36 | import org.sakaiproject.entitybroker.util.http.HttpRESTUtils; 37 | import org.sakaiproject.entitybroker.util.http.HttpRESTUtils.Method; 38 | import org.sakaiproject.entitybroker.util.http.HttpResponse; 39 | import org.sakaiproject.event.api.LearningResourceStoreProvider; 40 | import org.sakaiproject.event.api.LearningResourceStoreService.LRS_Statement; 41 | import org.sakaiproject.lrs.expapi.model.LRSKeys; 42 | import org.sakaiproject.lrs.expapi.util.StatementMapUtils; 43 | import org.slf4j.Logger; 44 | import org.slf4j.LoggerFactory; 45 | 46 | /** 47 | * TinCanAPI LRS provider that understands how to handle LRS statements to a TincanAPI service. 48 | * 49 | * @author Charles Hasegawa (chasegawa @ Unicon.net) 50 | * @author Aaron Zeckoski (azeckoski @ unicon.net) (azeckoski @ vt.edu) 51 | * @author Robert Long (rlong @ unicon.net) 52 | */ 53 | public class TincanapiLearningResourceStoreProvider implements LearningResourceStoreProvider, LRSKeys { 54 | 55 | private static final Logger log = LoggerFactory.getLogger(TincanapiLearningResourceStoreProvider.class); 56 | 57 | private static final String apiVersion = "1.0.0"; 58 | private static final HashMap EMPTY_PARAMS = new HashMap<>(0); 59 | private static final FastDateFormat FORMATTER = DateFormatUtils.ISO_DATETIME_TIME_ZONE_FORMAT; 60 | private static final String TEST_CONN_MESSAGE = "{\"actor\": {\"mbox\": \"mailto:no-reply@sakaiTCAPI.com\",\"name\": \"Sakai startup connection test\",\"objectType\": \"Agent\"},\"verb\": {\"id\": \"http://adlnet.gov/expapi/verbs/interacted\",\"display\": {\"en-US\": \"interacted\"}},\"object\": {\"id\": \"http://www.example.com/tincan/activities/OyeZsHFR\",\"objectType\": \"Activity\",\"definition\": {\"name\": {\"en-US\": \"Example Activity\"}}}}"; 61 | private static final boolean GUARANTEE_SSL = true; 62 | 63 | // config variables 64 | private String basicAuthString; 65 | private ServerConfigurationService serverConfigurationService; 66 | private String consumerKey; 67 | private String consumerSecret; 68 | private String id = "tincanapi"; 69 | private String realm; 70 | private int timeout = 0; 71 | private String url; 72 | 73 | // calculated variables 74 | private String configPrefix; 75 | private HashMap headers; 76 | private HttpClientWrapper httpClientWrapper; 77 | private JSONTranscoder jsonTranscoder; 78 | private OAuthServiceProvider oAuthServiceProvider; 79 | private OAuthAccessor oAuthAccessor; 80 | 81 | /** 82 | * @param {@link ServerConfigurationService} 83 | */ 84 | public TincanapiLearningResourceStoreProvider(ServerConfigurationService configurationService) { 85 | this.serverConfigurationService = configurationService; 86 | } 87 | 88 | /** 89 | * Build up the pieces needed for getting the two-step OAuth header values 90 | */ 91 | private void configForOAuth() { 92 | oAuthServiceProvider = new OAuthServiceProvider("notUsed", "notUsed", "notUsed"); 93 | oAuthAccessor = new OAuthAccessor(new OAuthConsumer("notUsed", consumerKey, consumerSecret, oAuthServiceProvider)); 94 | } 95 | 96 | /** 97 | * @return create a JSON string from the various elements of the supplied statement 98 | */ 99 | private String convertLRS_StatementToJSON(LRS_Statement statement) { 100 | HashMap statementMap = new HashMap<>(); 101 | 102 | // Actor, verb and object are required 103 | try { 104 | statementMap.put(LRSStatementKey.actor.toString(), StatementMapUtils.getActorMap(statement.getActor())); 105 | statementMap.put(LRSStatementKey.verb.toString(), StatementMapUtils.getVerbMap(statement.getVerb())); 106 | statementMap.put(LRSStatementKey.object.toString(), StatementMapUtils.getObjectMap(statement.getObject())); 107 | } catch (Exception e) { 108 | log.debug("Unable to handle supplied LRS_Statement", e); 109 | throw new IllegalArgumentException("Unable to handle supplied LRS_Statement.\nUnable to process Actor, Verb, or Object"); 110 | } 111 | 112 | if (null != statement.getContext()) { 113 | statementMap.put(LRSStatementKey.context.toString(), StatementMapUtils.getContextMap(statement.getContext())); 114 | } 115 | 116 | if (null != statement.getResult()) { 117 | statementMap.put(LRSStatementKey.result.toString(), StatementMapUtils.getResultMap(statement.getResult())); 118 | } 119 | 120 | if (null != statement.getStored()) { 121 | statementMap.put(LRSStatementKey.stored.toString(), FORMATTER.format(statement.getStored())); 122 | } 123 | 124 | if (null != statement.getTimestamp()) { 125 | statementMap.put(LRSStatementKey.timestamp.toString(), FORMATTER.format(statement.getTimestamp())); 126 | } 127 | 128 | return jsonTranscoder.encode(statementMap, null, null); 129 | } 130 | 131 | /** 132 | * Shutdown the provider 133 | */ 134 | public void destroy() { 135 | if (httpClientWrapper != null) { 136 | try { 137 | httpClientWrapper.shutdown(); 138 | } catch (Exception e) { 139 | log.error("Error upon destroying TinCanAPI provider.", e); 140 | } 141 | 142 | httpClientWrapper = null; 143 | } 144 | 145 | jsonTranscoder = null; 146 | } 147 | 148 | /** 149 | * Parse the data from the LRS statement and handle sending the request to the configured receiver. If there is an issue in 150 | * sending (due to endpoint being misconfigured or unavailable), we simply log the statement. 151 | * 152 | * @see org.sakaiproject.event.api.LearningResourceStoreProvider#handleStatement(org.sakaiproject.event.api.LearningResourceStoreService.LRS_Statement) 153 | */ 154 | public void handleStatement(LRS_Statement statement) { 155 | String data = null; 156 | 157 | if (statement.isPopulated()) { 158 | data = convertLRS_StatementToJSON(statement); 159 | log.debug("LRS using populated statement: {}", data); 160 | } else if (statement.getRawMap() != null && !statement.getRawMap().isEmpty()) { 161 | data = jsonTranscoder.encode(statement.getRawMap(), null, null); 162 | log.debug("LRS using raw Map statement: {}", data); 163 | } else { 164 | data = statement.getRawJSON(); 165 | log.debug("LRS using raw JSON statement: {}", data); 166 | } 167 | 168 | log.debug("LRS Attempting to handle statement: {}", statement); 169 | 170 | try { 171 | HttpResponse response = postData(data); 172 | if (response.getResponseCode() >= 200 && response.getResponseCode() < 300) { 173 | log.debug("{} LRS provider successfully sent statement data: {}", id, data); 174 | } else { 175 | log.warn("{} LRS provider failed ({} {}) sending statement data ({}) to ({}), response: {}", 176 | id, response.getResponseCode(), response.getResponseMessage(), data, url, response.getResponseBody()); 177 | } 178 | } catch (Exception e) { 179 | log.error("{} LRS provider exception: Statement was not sent.\n Statement data: {}", id, data, e); 180 | } 181 | } 182 | 183 | /** 184 | * Initialize the state of this provider, reading in the configuration. 185 | * 186 | * @throws URISyntaxException 187 | * @throws IOException 188 | * @throws OAuthException 189 | */ 190 | public void init() throws OAuthException, IOException, URISyntaxException { 191 | readConfig(); 192 | // Don't allow api version to be configured... we only should be reporting it 193 | log.info("{} LRS provider (version {}) configured: {}", id, apiVersion, url); 194 | 195 | headers = new HashMap<>(3); 196 | headers.put("Content-Type", "application/json"); 197 | headers.put("X-Experience-API-Version", apiVersion); 198 | 199 | if (StringUtils.isNotEmpty(basicAuthString)) { 200 | // Note that the SCORM example javascript 64 encoder does use + and / in their output, so we do NOT use the URL safe 201 | // version of this method here to match their logic 202 | headers.put("Authorization", "Basic " + Base64.encodeBase64String((basicAuthString).getBytes())); 203 | } else { 204 | configForOAuth(); 205 | } 206 | 207 | jsonTranscoder = new JSONTranscoder(true, true, false); 208 | httpClientWrapper = HttpRESTUtils.makeReusableHttpClient(true, timeout, null); 209 | HttpResponse response; 210 | 211 | try { 212 | response = postData(TEST_CONN_MESSAGE); 213 | 214 | if (response.getResponseCode() == 200) { 215 | log.info("{} LRS provider configured and ready", id); 216 | } else { 217 | log.error("{} LRS provider not configured properly OR LRS is offline - test message failed!", id); 218 | } 219 | } catch (Exception e) { 220 | log.error("{} LRS provider failure while trying to contact the LRS! Initialization test failed: ", id, e); 221 | } 222 | 223 | log.info("{} LRS provider INIT complete", id); 224 | } 225 | 226 | private HttpResponse postData(String data) throws OAuthException, IOException, URISyntaxException { 227 | if (StringUtils.isEmpty(basicAuthString)) { 228 | OAuthMessage message = oAuthAccessor.newRequestMessage(OAuthMessage.POST, url, null); 229 | message.sign(oAuthAccessor); 230 | String authHeader = message.getAuthorizationHeader(realm); 231 | headers.put("Authorization", authHeader); 232 | } 233 | HttpResponse response = HttpRESTUtils.fireRequest(httpClientWrapper, url, Method.POST, EMPTY_PARAMS, headers, data, GUARANTEE_SSL); 234 | 235 | return response; 236 | } 237 | 238 | /** 239 | * Read the setup from the configuration. All non-empty values will overwrite any values that may have been set due to DI 240 | * Values are prefixed with "lrs.[id]." so that multiple versions of this provider can be instantiated based on the id. 241 | */ 242 | private void readConfig() { 243 | if (StringUtils.isEmpty(id)) { 244 | throw new IllegalStateException("Invalid " + id + " for LRS provider, cannot start"); 245 | } 246 | 247 | configPrefix = "lrs." + id + "."; 248 | 249 | String value = serverConfigurationService.getConfig(configPrefix + "url", ""); 250 | url = StringUtils.isNotEmpty(value) ? value : url; 251 | 252 | // ensure the URL is valid and formatted the same way (add "/" if not on the end for example) 253 | UrlValidator urlValidator = new UrlValidator(UrlValidator.ALLOW_LOCAL_URLS); 254 | 255 | if (!urlValidator.isValid(url)) { 256 | throw new IllegalStateException("Invalid " + id + " LRS provider url (" + url + "), correct the " + configPrefix + "url config value"); 257 | } /* won't work with some LRS 258 | else { 259 | if (!url.endsWith("/")) { 260 | url = url + "/"; 261 | } 262 | }*/ 263 | 264 | value = serverConfigurationService.getConfig(configPrefix + "request.timeout", ""); 265 | 266 | try { 267 | timeout = StringUtils.isNotEmpty(value) ? Integer.parseInt(value) : timeout; // allow setter to override 268 | } catch (NumberFormatException e) { 269 | timeout = 0; 270 | log.debug("{} request.timeout must be an integer value - using default setting", configPrefix, e); 271 | } 272 | 273 | // basic auth 274 | value = serverConfigurationService.getConfig(configPrefix + "basicAuthUserPass", ""); 275 | basicAuthString = StringUtils.isNotEmpty(value) ? value : basicAuthString; 276 | 277 | // oauth fields 278 | value = serverConfigurationService.getConfig(configPrefix + "consumer.key", ""); 279 | consumerKey = StringUtils.isNotEmpty(value) ? value : consumerKey; 280 | value = serverConfigurationService.getConfig(configPrefix + "consumer.secret", ""); 281 | consumerSecret = StringUtils.isNotEmpty(value) ? value : consumerSecret; 282 | value = serverConfigurationService.getConfig(configPrefix + "realm", ""); 283 | realm = StringUtils.isNotEmpty(value) ? value : realm; 284 | 285 | if (StringUtils.isEmpty(basicAuthString) && (StringUtils.isEmpty(consumerKey) || StringUtils.isEmpty(consumerSecret) || StringUtils.isEmpty(realm))) { 286 | throw new IllegalStateException("No authentication configured properly for LRS provider, service cannot start. Please check the configuration"); 287 | } 288 | } 289 | 290 | public void setBasicAuthString(String authString) { 291 | this.basicAuthString = authString; 292 | } 293 | 294 | public void setConsumerKey(String consumerKey) { 295 | this.consumerKey = consumerKey; 296 | } 297 | 298 | public void setConsumerSecret(String consumerSecret) { 299 | this.consumerSecret = consumerSecret; 300 | } 301 | 302 | /** 303 | * @see org.sakaiproject.event.api.LearningResourceStoreProvider#getID() 304 | */ 305 | public String getID() { 306 | return id; 307 | } 308 | 309 | public void setId(String id) { 310 | this.id = id; 311 | } 312 | 313 | public void setRealm(String realm) { 314 | this.realm = realm; 315 | } 316 | 317 | public void setServerConfigurationService(ServerConfigurationService serverConfigurationService) { 318 | this.serverConfigurationService = serverConfigurationService; 319 | } 320 | 321 | public void setTimeout(int timeout) { 322 | this.timeout = timeout; 323 | } 324 | 325 | public void setUrl(String url) { 326 | this.url = url; 327 | } 328 | 329 | } 330 | --------------------------------------------------------------------------------