├── dev └── user.clj ├── doc └── intro.md ├── dev-resources └── data │ ├── objects │ ├── agent.json │ ├── activity │ │ └── definition │ │ │ └── interaction │ │ │ ├── fill-in.json │ │ │ ├── numeric.json │ │ │ ├── other.json │ │ │ ├── true-false.json │ │ │ ├── long-fill-in.json │ │ │ ├── performance.json │ │ │ ├── sequencing.json │ │ │ ├── likert.json │ │ │ ├── choice.json │ │ │ └── matching.json │ ├── authority.json │ ├── sub-statement.json │ ├── activity.json │ └── group.json │ └── statements │ ├── void.json │ ├── completion.json │ ├── simple.json │ └── long.json ├── .dir-locals.el ├── Makefile ├── .gitignore ├── deps.edn ├── src └── xapi_schema │ ├── spec │ ├── util.cljc │ ├── regex.cljc │ └── resources.cljc │ ├── core.cljc │ └── spec.cljc ├── .github └── workflows │ ├── main.yml │ └── deploy.yml ├── test └── xapi_schema │ ├── support │ ├── spec.cljc │ └── data.cljc │ ├── runner.cljc │ ├── spec │ ├── resources_test.cljc │ └── regex_test.cljc │ ├── core_test.cljc │ └── spec_test.cljc ├── pom.xml ├── README.org └── LICENSE /dev/user.clj: -------------------------------------------------------------------------------- 1 | (ns user 2 | (:require 3 | [clojure.repl :refer [doc source]])) 4 | -------------------------------------------------------------------------------- /doc/intro.md: -------------------------------------------------------------------------------- 1 | # Introduction to xapi-schema 2 | 3 | TODO: write [great documentation](http://jacobian.org/writing/what-to-write/) 4 | -------------------------------------------------------------------------------- /dev-resources/data/objects/agent.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Andrew Downes", 3 | "mbox": "mailto:andrew@example.co.uk", 4 | "objectType": "Agent" 5 | } 6 | -------------------------------------------------------------------------------- /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ;;; Directory Local Variables 2 | ;;; For more information see (info "(emacs) Directory Variables") 3 | 4 | ((nil . 5 | ((cider-clojure-cli-global-options . "-A:dev")))) 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .phony: clean repl test-clj test-cljs ci 2 | clean: 3 | rm -rf target pom.xml.asc logs out 4 | repl: 5 | clj -A:dev -r 6 | test-clj: 7 | clojure -A:dev -m xapi-schema.runner 8 | test-cljs: 9 | clojure -A:dev -m cljs.main -co build.test.edn -c 10 | node out/main.js 11 | ci: test-clj test-cljs 12 | -------------------------------------------------------------------------------- /dev-resources/data/objects/activity/definition/interaction/fill-in.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "en-US": "Ben is often heard saying: " 4 | }, 5 | "type": "http://adlnet.gov/expapi/activities/cmi.interaction", 6 | "interactionType": "fill-in", 7 | "correctResponsesPattern": [ 8 | "Bob's your uncle" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.cljs_node_repl 2 | /.cljs_rhino_repl 3 | /out 4 | /target 5 | /node_modules 6 | /package.json 7 | /package-lock.json 8 | /classes 9 | /checkouts 10 | /pom.xml.asc 11 | *.jar 12 | *.class 13 | /.lein-* 14 | /.nrepl-port 15 | .DS_Store 16 | /resources/public/xapi_schema.js 17 | .specljs-timestamp 18 | /.cpcache 19 | /.calva 20 | /.clj-kondo 21 | /.lsp 22 | -------------------------------------------------------------------------------- /dev-resources/data/objects/activity/definition/interaction/numeric.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "en-US": "How many jokes is Chris the butt of each day?" 4 | }, 5 | "type": "http://adlnet.gov/expapi/activities/cmi.interaction", 6 | "interactionType": "numeric", 7 | "correctResponsesPattern": [ 8 | "4[:]" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /dev-resources/data/objects/activity/definition/interaction/other.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "en-US": "On this map, please mark Franklin, TN" 4 | }, 5 | "type": "http://adlnet.gov/expapi/activities/cmi.interaction", 6 | "interactionType": "other", 7 | "correctResponsesPattern": [ 8 | "(35.937432,-86.868896)" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /dev-resources/data/objects/activity/definition/interaction/true-false.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "en-US": "Does the xAPI include the concept of statements?" 4 | }, 5 | "type": "http://adlnet.gov/expapi/activities/cmi.interaction", 6 | "interactionType": "true-false", 7 | "correctResponsesPattern": [ 8 | "true" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /dev-resources/data/objects/authority.json: -------------------------------------------------------------------------------- 1 | { 2 | "objectType" : "Group", 3 | "member": [ 4 | { 5 | "account": { 6 | "homePage":"http://example.com/xAPI/OAuth/Token", 7 | "name":"oauth_consumer_x75db" 8 | } 9 | }, 10 | { 11 | "mbox":"mailto:bob@example.com" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /dev-resources/data/objects/activity/definition/interaction/long-fill-in.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "en-US": "What is the purpose of the xAPI?" 4 | }, 5 | "type": "http://adlnet.gov/expapi/activities/cmi.interaction", 6 | "interactionType": "long-fill-in", 7 | "correctResponsesPattern": [ 8 | "{case_matters=false}{lang=en}To store and provide access to learning experiences." 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /dev-resources/data/objects/sub-statement.json: -------------------------------------------------------------------------------- 1 | { 2 | "objectType": "SubStatement", 3 | "actor" : { 4 | "objectType": "Agent", 5 | "mbox":"mailto:agent@example.com" 6 | }, 7 | "verb" : { 8 | "id":"http://example.com/confirmed", 9 | "display":{ 10 | "en":"confirmed" 11 | } 12 | }, 13 | "object": { 14 | "objectType":"StatementRef", 15 | "id" :"9e13cefd-53d3-4eac-b5ed-2cf6693903bb" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /dev-resources/data/statements/void.json: -------------------------------------------------------------------------------- 1 | { 2 | "actor" : { 3 | "objectType": "Agent", 4 | "name" : "Example Admin", 5 | "mbox" : "mailto:admin@example.adlnet.gov" 6 | }, 7 | "verb" : { 8 | "id":"http://adlnet.gov/expapi/verbs/voided", 9 | "display":{ 10 | "en-US":"voided" 11 | } 12 | }, 13 | "object" : { 14 | "objectType":"StatementRef", 15 | "id" : "e05aa883-acaf-40ad-bf54-02c8ce485fb0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /dev-resources/data/objects/activity.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "http://www.example.co.uk/exampleactivity", 3 | "definition": { 4 | "name": { 5 | "en-GB": "example activity", 6 | "en-US": "example activity" 7 | }, 8 | "description": { 9 | "en-GB": "An example of an activity", 10 | "en-US": "An example of an activity" 11 | }, 12 | "type": "http://www.example.co.uk/types/exampleactivitytype" 13 | }, 14 | "objectType": "Activity" 15 | } 16 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps 3 | {org.clojure/clojure {:mvn/version "1.9.0"} 4 | org.clojure/clojurescript {:mvn/version "1.10.339" #_"1.9.946"} 5 | org.clojure/data.json {:mvn/version "0.2.6"}} 6 | :aliases 7 | {:dev 8 | {:extra-paths ["test" "dev" "dev-resources"] 9 | :extra-deps 10 | {cider/piggieback {:mvn/version "0.5.2"} 11 | org.clojure/test.check {:mvn/version "0.10.0-alpha2"} 12 | com.walmartlabs/lacinia 13 | {:mvn/version "0.25.0" 14 | :exclusions [org.clojure/clojure 15 | clojure-future-spec/clojure-future-spec]}}}}} 16 | -------------------------------------------------------------------------------- /dev-resources/data/objects/group.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Example Group", 3 | "account" : { 4 | "homePage" : "http://example.com/homePage", 5 | "name" : "GroupAccount" 6 | }, 7 | "objectType": "Group", 8 | "member": [ 9 | { 10 | "name": "Andrew Downes", 11 | "mbox": "mailto:andrew@example.com", 12 | "objectType": "Agent" 13 | }, 14 | { 15 | "name": "Aaron Silvers", 16 | "openid": "http://aaron.openid.example.org", 17 | "objectType": "Agent" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/xapi_schema/spec/util.cljc: -------------------------------------------------------------------------------- 1 | (ns xapi-schema.spec.util 2 | (:require [clojure.spec.alpha :as s :include-macros true] 3 | [clojure.spec.gen.alpha :as sgen :include-macros true]) 4 | #?(:cljs (:require-macros [xapi-schema.spec.util :refer [with-conformer]]))) 5 | 6 | #?(:clj (defmacro with-conformer 7 | "Given a base spec and functions to con/unform, return a spec that 8 | will use 9 | " 10 | [spec conform-fn unform-fn & [gen]] 11 | `(s/with-gen (s/and 12 | (s/conformer ~conform-fn ~unform-fn) 13 | ~spec) 14 | #(sgen/fmap 15 | ~unform-fn 16 | ~(or 17 | gen 18 | `(s/gen ~spec)))))) 19 | -------------------------------------------------------------------------------- /dev-resources/data/statements/completion.json: -------------------------------------------------------------------------------- 1 | { 2 | "actor":{ 3 | "objectType": "Agent", 4 | "name":"Example Learner", 5 | "mbox":"mailto:example.learner@adlnet.gov" 6 | }, 7 | "verb":{ 8 | "id":"http://adlnet.gov/expapi/verbs/attempted", 9 | "display":{ 10 | "en-US":"attempted" 11 | } 12 | }, 13 | "object":{ 14 | "id":"http://example.adlnet.gov/xapi/example/simpleCBT", 15 | "definition":{ 16 | "name":{ 17 | "en-US":"simple CBT course" 18 | }, 19 | "description":{ 20 | "en-US":"A fictitious example CBT course." 21 | } 22 | } 23 | }, 24 | "result":{ 25 | "score":{ 26 | "scaled":0.95 27 | }, 28 | "success":true, 29 | "completion":true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: [push, pull_request, workflow_dispatch] 7 | 8 | jobs: 9 | test: 10 | # The type of runner that the job will run on 11 | runs-on: ubuntu-latest 12 | 13 | # Steps represent a sequence of tasks that will be executed as part of the job 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup CI environment 19 | uses: yetanalytics/action-setup-env@v2 20 | 21 | - name: Execute Clojure Tests 22 | run: clojure -A:dev -m xapi-schema.runner 23 | 24 | - name: Build ClojureScript Tests 25 | run: clojure -A:dev -m cljs.main -co build.test.edn -c 26 | 27 | - name: Run ClojureScript Tests 28 | run: node out/main.js 29 | -------------------------------------------------------------------------------- /dev-resources/data/statements/simple.json: -------------------------------------------------------------------------------- 1 | { 2 | "id":"fd41c918-b88b-4b20-a0a5-a4c32391aaa0", 3 | "actor":{ 4 | "objectType": "Agent", 5 | "name":"Project Tin Can API", 6 | "mbox":"mailto:user@example.com" 7 | }, 8 | "verb":{ 9 | "id":"http://example.com/xapi/verbs#sent-a-statement", 10 | "display":{ 11 | "en-US":"sent" 12 | } 13 | }, 14 | "object":{ 15 | "id":"http://example.com/xapi/activity/simplestatement", 16 | "definition":{ 17 | "name":{ 18 | "en-US":"simple statement" 19 | }, 20 | "description":{ 21 | "en-US":"A simple Experience API statement. Note that the LRS 22 | does not need to have any prior information about the Actor (learner), the 23 | verb, or the Activity/object." 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' # Enforce Semantic Versioning 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup CD Environment 17 | uses: yetanalytics/action-setup-env@v2 18 | 19 | - name: Extract version 20 | id: version 21 | run: echo version=${GITHUB_REF#refs\/tags\/v} >> $GITHUB_OUTPUT 22 | 23 | - name: Build and deploy to Clojars 24 | uses: yetanalytics/action-deploy-clojars@v2 25 | with: 26 | artifact-id: 'xapi-schema' 27 | src-dirs: '["src"]' 28 | resource-dirs: '[]' 29 | version: ${{ steps.version.outputs.version }} 30 | clojars-username: ${{ secrets.CLOJARS_USERNAME }} 31 | clojars-deploy-token: ${{ secrets.CLOJARS_PASSWORD }} 32 | -------------------------------------------------------------------------------- /dev-resources/data/objects/activity/definition/interaction/performance.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "en-US": "This interaction measures performance over a day of RS sports:" 4 | }, 5 | "type": "http://adlnet.gov/expapi/activities/cmi.interaction", 6 | "interactionType": "performance", 7 | "correctResponsesPattern": [ 8 | "pong[.]1:[,]dg[.]:10[,]lunch[.]" 9 | ], 10 | "steps": [ 11 | { 12 | "id": "pong", 13 | "description": { 14 | "en-US": "Net pong matches won" 15 | } 16 | }, 17 | { 18 | "id": "dg", 19 | "description": { 20 | "en-US": "Strokes over par in disc golf at Liberty" 21 | } 22 | }, 23 | { 24 | "id": "lunch", 25 | "description": { 26 | "en-US": "Lunch having been eaten" 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /dev-resources/data/objects/activity/definition/interaction/sequencing.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "en-US": "Order players by their pong ladder position:" 4 | }, 5 | "type": "http://adlnet.gov/expapi/activities/cmi.interaction", 6 | "interactionType": "sequencing", 7 | "correctResponsesPattern": [ 8 | "tim[,]mike[,]ells[,]ben" 9 | ], 10 | "choices": [ 11 | { 12 | "id": "tim", 13 | "description": { 14 | "en-US": "Tim" 15 | } 16 | }, 17 | { 18 | "id": "ben", "description": { 19 | "en-US": "Ben" 20 | } 21 | }, 22 | { 23 | "id": "ells", 24 | "description": { 25 | "en-US": "Ells" 26 | } 27 | }, 28 | { 29 | "id": "mike", 30 | "description": { 31 | "en-US": "Mike" 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /dev-resources/data/objects/activity/definition/interaction/likert.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "en-US": "How awesome is Experience API?" 4 | }, 5 | "type": "http://adlnet.gov/expapi/activities/cmi.interaction", 6 | "interactionType": "likert", 7 | "correctResponsesPattern": [ 8 | "likert_3" 9 | ], 10 | "scale": [ 11 | { 12 | "id": "likert_0", 13 | "description": { 14 | "en-US": "It's OK" 15 | } 16 | }, 17 | { 18 | "id": "likert_1", 19 | "description": { 20 | "en-US": "It's Pretty Cool" 21 | } 22 | }, 23 | { 24 | "id": "likert_2", 25 | "description": { 26 | "en-US": "It's Damn Cool" 27 | } 28 | }, 29 | { 30 | "id": "likert_3", 31 | "description": { 32 | "en-US": "It's Gonna Change the World" 33 | } 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /dev-resources/data/objects/activity/definition/interaction/choice.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "en-US": "Which of these prototypes are available at the beta site?" 4 | }, 5 | "type": "http://adlnet.gov/expapi/activities/cmi.interaction", 6 | "interactionType": "choice", 7 | "correctResponsesPattern": [ 8 | "golf[,]tetris" 9 | ], 10 | "choices": [ 11 | { 12 | "id": "golf", 13 | "description": { 14 | "en-US": "Golf Example" 15 | } 16 | }, 17 | { 18 | "id": "facebook", 19 | "description": { 20 | "en-US": "Facebook App" 21 | } 22 | }, 23 | { 24 | "id": "tetris", 25 | "description": { 26 | "en-US": "Tetris Example" 27 | } 28 | }, 29 | { 30 | "id": "scrabble", 31 | "description": { 32 | "en-US": "Scrabble Example" 33 | } 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /test/xapi_schema/support/spec.cljc: -------------------------------------------------------------------------------- 1 | (ns xapi-schema.support.spec 2 | (:require [clojure.test :refer [is] :include-macros true] 3 | [clojure.spec.alpha :as s :include-macros true])) 4 | 5 | (defn should-satisfy [spec data] 6 | (is (nil? (s/explain-data spec data)))) 7 | 8 | (defn should-not-satisfy [spec data] 9 | (is (not (nil? (s/explain-data spec data))))) 10 | 11 | (defn should-satisfy+ 12 | [spec & goods-bads] 13 | (let [[goods _ bads] (partition-by #(= :bad %) goods-bads) 14 | checked-bad-spec (when bads (map (partial s/explain-data spec) 15 | bads))] 16 | (is (nil? (s/explain-data (s/coll-of spec) goods))) 17 | (when bads 18 | (is (not (some nil? checked-bad-spec)))))) 19 | 20 | (defn key-should-satisfy+ [spec 21 | base 22 | key 23 | & goods-bads] 24 | (let [[goods _ bads] (partition-by #(= :bad %) goods-bads) 25 | gbs (concat 26 | (map 27 | #(assoc base key %) 28 | goods) 29 | '(:bad) 30 | (map 31 | #(assoc base key %) 32 | bads))] 33 | (apply 34 | should-satisfy+ 35 | spec 36 | gbs))) 37 | -------------------------------------------------------------------------------- /test/xapi_schema/runner.cljc: -------------------------------------------------------------------------------- 1 | (ns xapi-schema.runner 2 | (:require [#?(:clj clojure.test 3 | :cljs cljs.test) :refer [run-tests]] 4 | xapi-schema.core-test 5 | xapi-schema.spec-test 6 | xapi-schema.spec.regex-test 7 | xapi-schema.spec.resources-test 8 | #?@(:cljs [[cljs.nodejs :refer [process]]]))) 9 | ;; Exit properly for cljs tests 10 | #?(:cljs (defmethod cljs.test/report [:cljs.test/default :end-run-tests] [m] 11 | (.exit process 12 | (if (cljs.test/successful? m) 13 | 0 14 | 1)))) 15 | 16 | #?(:clj (defn -main [] 17 | (let [{:keys [test pass fail error] :as result} 18 | (run-tests 'xapi-schema.core-test 19 | 'xapi-schema.spec-test 20 | 'xapi-schema.spec.regex-test 21 | 'xapi-schema.spec.resources-test)] 22 | (System/exit (if (= 0 fail error) 23 | 0 24 | 1)))) 25 | :cljs (set! *main-cli-fn* 26 | (fn [] 27 | (run-tests 'xapi-schema.core-test 28 | 'xapi-schema.spec-test 29 | 'xapi-schema.spec.regex-test 30 | 'xapi-schema.spec.resources-test 31 | )))) 32 | -------------------------------------------------------------------------------- /src/xapi_schema/core.cljc: -------------------------------------------------------------------------------- 1 | (ns xapi-schema.core 2 | (:require 3 | [clojure.spec.alpha :as s :include-macros true] 4 | [xapi-schema.spec :as xapispec] 5 | #?(:clj [clojure.data.json :as json]))) 6 | 7 | (def statement-checker 8 | (partial s/explain-data ::xapispec/statement)) 9 | 10 | (def statements-checker 11 | (partial s/explain-data ::xapispec/statements)) 12 | 13 | (defn validate-statement [s] 14 | (if (s/valid? ::xapispec/statement s) 15 | s 16 | (throw 17 | (ex-info "Statement Invalid" 18 | {:type ::statement-invalid 19 | :statement s 20 | :error (statement-checker s)})))) 21 | 22 | (defn validate-statements [ss] 23 | (if (s/valid? ::xapispec/statements ss) 24 | ss 25 | (throw 26 | (ex-info "Statements Invalid" 27 | {:type ::statements-invalid 28 | :statements ss 29 | :error (statements-checker ss)})))) 30 | 31 | (defn validate-statement-data* [sd] 32 | (if (map? sd) 33 | (validate-statement sd) 34 | (validate-statements sd))) 35 | 36 | (defn validate-statement-data [sd] 37 | #?(:clj (validate-statement-data* (cond 38 | (string? sd) (json/read-str sd) 39 | :else sd)) 40 | :cljs (validate-statement-data* 41 | (cond 42 | (string? sd) (js->clj (.parse js/JSON sd)) 43 | :else sd)))) 44 | 45 | #?(:cljs 46 | (defn ^:export validate-statement-data-js 47 | [sd] 48 | (clj->js (validate-statement-data (js->clj sd))))) 49 | -------------------------------------------------------------------------------- /dev-resources/data/objects/activity/definition/interaction/matching.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "en-US": "Match these people to their kickball team:" 4 | }, 5 | "type": "http://adlnet.gov/expapi/activities/cmi.interaction", 6 | "interactionType": "matching", 7 | "correctResponsesPattern": [ 8 | "ben[.]3[,]chris[.]2[,]troy[.]4[,]freddie[.]1" 9 | ], 10 | "source": [ 11 | { 12 | "id": "ben", 13 | "description": { 14 | "en-US": "Ben" 15 | } 16 | }, 17 | { 18 | "id": "chris", 19 | "description": { 20 | "en-US": "Chris" 21 | } 22 | }, 23 | { 24 | "id": "troy", 25 | "description": { 26 | "en-US": "Troy" 27 | } 28 | }, 29 | { 30 | "id": "freddie", 31 | "description": { 32 | "en-US": "Freddie" 33 | } 34 | } 35 | ], 36 | "target": [ 37 | { 38 | "id": "1", 39 | "description": { 40 | "en-US": "Swift Kick in the Grass" 41 | } 42 | }, 43 | { 44 | "id": "2", 45 | "description": { 46 | "en-US": "We got Runs" 47 | } 48 | }, 49 | { 50 | "id": "3", 51 | "description": { 52 | "en-US": "Duck" 53 | } 54 | }, 55 | { 56 | "id": "4", 57 | "description": { 58 | "en-US": "Van Delay Industries" 59 | } 60 | } 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /test/xapi_schema/spec/resources_test.cljc: -------------------------------------------------------------------------------- 1 | (ns xapi-schema.spec.resources-test 2 | (:require [clojure.test :refer [deftest is testing] :include-macros true] 3 | [clojure.spec.alpha :as s :include-macros true] 4 | [xapi-schema.spec.resources :as xsr :refer [*read-json-fn* 5 | *write-json-fn* 6 | json-string-conformer]])) 7 | 8 | (deftest parse-json-test 9 | (is (= {"foo" "bar"} 10 | (*read-json-fn* "{\"foo\":\"bar\"}")))) 11 | 12 | (deftest unparse-json-test 13 | (is (= "{\"foo\":\"bar\"}" 14 | (*write-json-fn* {"foo" "bar"})))) 15 | 16 | (deftest json-string-conformer-test 17 | (is (= {"foo" "bar"} 18 | (s/conform json-string-conformer 19 | "{\"foo\":\"bar\"}"))) 20 | (is (s/valid? json-string-conformer 21 | "{\"foo\":\"bar\"}")) 22 | (is (not 23 | (s/valid? json-string-conformer 24 | "{\"foo\":\"bar\""))) 25 | (is (= {"foo" "bar"} 26 | (s/conform json-string-conformer 27 | {"foo" "bar"}))) 28 | (is (= "{\"foo\":\"bar\"}" 29 | (s/unform json-string-conformer 30 | {"foo" "bar"}))) 31 | (is (= "{\"foo\":\"bar\"}" 32 | (s/unform json-string-conformer 33 | "{\"foo\":\"bar\"}"))) 34 | ) 35 | 36 | (deftest agent-param-test 37 | (is (s/valid? :xapi.common.param/agent 38 | "{\"mbox\":\"mailto:milt@yetanalytics.com\"}")) 39 | (is (not (s/valid? :xapi.common.param/agent 40 | "{\"mbox\":\"milt@yetanalytics.com\"}"))) 41 | (is (not (s/valid? :xapi.common.param/agent 42 | "{\"email\":\"mailto:milt@yetanalytics.com\"}")))) 43 | 44 | (deftest statements-get-params-test 45 | (is (s/valid? :xapi.statements.GET.request/params 46 | {:statementId (str #?(:clj (java.util.UUID/randomUUID) 47 | :cljs (random-uuid))) 48 | :format "ids"})) 49 | #_(is (not 50 | (s/valid? :xapi.statements.GET.request/params 51 | {"statementId" (str #?(:clj (java.util.UUID/randomUUID) 52 | :cljs (random-uuid))) 53 | "ascending" true}))) 54 | (is (s/valid? :xapi.statements.GET.request/params 55 | {:ascending true 56 | :format "ids"}))) 57 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | com.yetanalytics 5 | xapi-schema 6 | jar 7 | 1.0.0-alpha19-SNAPSHOT 8 | xapi-schema 9 | Clojure(script) Schema for the Experience API v1.0.3 10 | https://github.com/yetanalytics/xapi-schema 11 | 12 | 13 | Eclipse Public License 14 | http://www.eclipse.org/legal/epl-v10.html 15 | 16 | 17 | 18 | https://github.com/yetanalytics/xapi-schema 19 | scm:git:git://github.com/yetanalytics/xapi-schema.git 20 | scm:git:ssh://git@github.com/yetanalytics/xapi-schema.git 21 | e9d4af0a06fc327cbd966bd5640d150036067a4d 22 | 23 | 24 | 25 | org.clojure 26 | clojure 27 | 1.9.0 28 | 29 | 30 | org.clojure 31 | clojurescript 32 | 1.10.339 33 | 34 | 35 | org.clojure 36 | data.json 37 | 0.2.6 38 | 39 | 40 | 41 | src 42 | 43 | 44 | src 45 | 46 | 47 | 48 | 49 | 50 | clojars 51 | https://repo.clojars.org/ 52 | 53 | 54 | 55 | 56 | clojars 57 | https://repo.clojars.org/ 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /test/xapi_schema/core_test.cljc: -------------------------------------------------------------------------------- 1 | (ns xapi-schema.core-test 2 | (:require 3 | [clojure.test :refer [deftest is testing] :include-macros true] 4 | [xapi-schema.support.data :as d :refer [long-statement]] 5 | [xapi-schema.core :refer [statement-checker 6 | statements-checker 7 | validate-statement 8 | validate-statements 9 | validate-statement-data 10 | #?(:cljs validate-statement-data-js)]] 11 | #?(:clj [clojure.data.json :as json] 12 | :cljs [cljs.core :refer [ExceptionInfo]])) 13 | #?(:clj (:import [clojure.lang ExceptionInfo]))) 14 | 15 | (deftest statement-checker-test 16 | (testing "with a valid statement" 17 | (is (nil? (statement-checker long-statement)))) 18 | 19 | (testing "with an invalid statement" 20 | (is (not (nil? (statement-checker (dissoc long-statement "object"))))))) 21 | 22 | (deftest statements-checker-test 23 | (testing "with all valid statements" 24 | (is (nil? (statements-checker [long-statement 25 | long-statement 26 | long-statement])))) 27 | 28 | (testing "with any invalid statements" 29 | (is (not (nil? (statements-checker [long-statement 30 | long-statement 31 | (dissoc long-statement "object")])))))) 32 | 33 | (deftest validate-statement-test 34 | (testing "with a single statement" 35 | (testing "with a valid statement in edn" 36 | (is (= long-statement (validate-statement long-statement)))) 37 | (testing "with an invalid statement" 38 | (is (= :xapi-schema.core/statement-invalid 39 | (try (validate-statement {"bad" "statement"}) 40 | (catch ExceptionInfo ei 41 | (some-> ei ex-data :type)))))))) 42 | 43 | (deftest validate-statements-test 44 | (testing "with multiple statements" 45 | (testing "with a valid statement in edn" 46 | (is (= (vector long-statement) (validate-statements (vector long-statement))))) 47 | (testing "with invalid statements" 48 | (is (= :xapi-schema.core/statements-invalid 49 | (try (validate-statements [{"bad" "statement"}]) 50 | (catch ExceptionInfo ei 51 | (some-> ei ex-data :type)))))))) 52 | 53 | (deftest validate-statement-data-test 54 | (testing "with a single statement" 55 | (testing "with a valid statement in edn" 56 | (is (= long-statement (validate-statement-data long-statement)))) 57 | (testing "with an invalid statement" 58 | (is (= :xapi-schema.core/statement-invalid 59 | (try (validate-statement-data {"bad" "statement"}) 60 | (catch ExceptionInfo ei 61 | (some-> ei ex-data :type))))))) 62 | 63 | (testing "with multiple statements" 64 | (testing "with valid statements in edn" 65 | (is (= (vector long-statement) (validate-statement-data (vector long-statement))))) 66 | (testing "with invalid statements" 67 | (is (= :xapi-schema.core/statements-invalid 68 | (try (validate-statement-data [{"bad" "statement"}]) 69 | (catch ExceptionInfo ei 70 | (some-> ei ex-data :type))))))) 71 | 72 | #?(:clj 73 | (testing "with string data" 74 | (let [statement (json/write-str long-statement)] 75 | (testing "it parses and returns the validated data" 76 | (is (= long-statement (validate-statement-data statement)))))) 77 | :cljs 78 | (testing "with string data" 79 | (let [json (clj->js long-statement) 80 | json-str (.stringify js/JSON json)] 81 | (testing "it parses and returns the validated data" 82 | (is (= long-statement (validate-statement-data json-str))))))) 83 | 84 | #?(:cljs 85 | (testing "with nested data" 86 | (let [statement long-statement] 87 | (testing "it coerces and returns the data" 88 | (is (= long-statement (validate-statement-data statement)))))))) 89 | 90 | #?(:cljs 91 | (deftest validate-statement-data-js-test 92 | (testing "with a JS object" 93 | (let [js-statement (clj->js long-statement)] 94 | (is (= (long-statement "id") (aget js-statement "id"))) ; just verifying this is a JS object 95 | (is (= (long-statement "id") (aget (validate-statement-data-js js-statement) "id"))))))) 96 | -------------------------------------------------------------------------------- /dev-resources/data/statements/long.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "6690e6c9-3ef0-4ed3-8b37-7f3964730bee", 3 | "actor": { 4 | "name": "Team PB", 5 | "mbox": "mailto:teampb@example.com", 6 | "member": [ 7 | { 8 | "name": "Andrew Downes", 9 | "account": { 10 | "homePage": "http://www.example.com", 11 | "name": "13936749" 12 | }, 13 | "objectType": "Agent" 14 | }, 15 | { 16 | "name": "Toby Nichols", 17 | "openid": "http://toby.openid.example.org/", 18 | "objectType": "Agent" 19 | }, 20 | { 21 | "name": "Ena Hills", 22 | "mbox_sha1sum": "ebd31e95054c018b10727ccffd2ef2ec3a016ee9", 23 | "objectType": "Agent" 24 | } 25 | ], 26 | "objectType": "Group" 27 | }, 28 | "verb": { 29 | "id": "http://adlnet.gov/expapi/verbs/attended", 30 | "display": { 31 | "en-GB": "attended", 32 | "en-US": "attended" 33 | } 34 | }, 35 | "result": { 36 | "extensions": { 37 | "http://example.com/profiles/meetings/resultextensions/minuteslocation": "X:\\meetings\\minutes\\examplemeeting.one" 38 | }, 39 | "success": true, 40 | "completion": true, 41 | "response": "We agreed on some example actions.", 42 | "duration": "PT1H0M0S" 43 | }, 44 | "context": { 45 | "registration": "ec531277-b57b-4c15-8d91-d292c5b2b8f7", 46 | "contextActivities": { 47 | "parent": [ 48 | { 49 | "id": "http://www.example.com/meetings/series/267", 50 | "objectType": "Activity" 51 | } 52 | ], 53 | "category": [ 54 | { 55 | "id": "http://www.example.com/meetings/categories/teammeeting", 56 | "objectType": "Activity", 57 | "definition": { 58 | "name": { 59 | "en": "team meeting" 60 | }, 61 | "description": { 62 | "en": "A category of meeting used for regular team meetings." 63 | }, 64 | "type": "http://example.com/expapi/activities/meetingcategory" 65 | } 66 | } 67 | ], 68 | "other": [ 69 | { 70 | "id": "http://www.example.com/meetings/occurances/34257", 71 | "objectType": "Activity" 72 | }, 73 | { 74 | "id": "http://www.example.com/meetings/occurances/3425567", 75 | "objectType": "Activity" 76 | } 77 | ] 78 | }, 79 | "instructor" : 80 | { 81 | "name": "Andrew Downes", 82 | "account": { 83 | "homePage": "http://www.example.com", 84 | "name": "13936749" 85 | }, 86 | "objectType": "Agent" 87 | }, 88 | "team": 89 | { 90 | "name": "Team PB", 91 | "mbox": "mailto:teampb@example.com", 92 | "objectType": "Group" 93 | }, 94 | "platform" : "Example virtual meeting software", 95 | "language" : "tlh", 96 | "statement" : { 97 | "objectType":"StatementRef", 98 | "id" :"6690e6c9-3ef0-4ed3-8b37-7f3964730bee" 99 | } 100 | 101 | }, 102 | "timestamp": "2013-05-18T05:32:34.804Z", 103 | "stored": "2013-05-18T05:32:34.804Z", 104 | "authority": { 105 | "account": { 106 | "homePage": "http://cloud.scorm.com/", 107 | "name": "anonymous" 108 | }, 109 | "objectType": "Agent" 110 | }, 111 | "version": "1.0.0", 112 | "object": { 113 | "id": "http://www.example.com/meetings/occurances/34534", 114 | "definition": { 115 | "extensions": { 116 | "http://example.com/profiles/meetings/activitydefinitionextensions/room": {"name": "Kilby", "id" : "http://example.com/rooms/342"} 117 | }, 118 | "name": { 119 | "en-GB": "example meeting", 120 | "en-US": "example meeting" 121 | }, 122 | "description": { 123 | "en-GB": "An example meeting that happened on a specific occasion with certain people present.", 124 | "en-US": "An example meeting that happened on a specific occasion with certain people present." 125 | }, 126 | "type": "http://adlnet.gov/expapi/activities/meeting", 127 | "moreInfo": "http://virtualmeeting.example.com/345256" 128 | }, 129 | "objectType": "Activity" 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /test/xapi_schema/support/data.cljc: -------------------------------------------------------------------------------- 1 | (ns xapi-schema.support.data 2 | #?(:clj (:require [clojure.data.json :as json] 3 | [clojure.java.io :as io]) 4 | :cljs (:require-macros [xapi-schema.support.data :refer [load-json 5 | load-json-map]]))) 6 | 7 | ;; Load test json files 8 | #?(:clj 9 | (defmacro load-json 10 | "load a json file as edn (string keys)" 11 | [path] 12 | (with-open [r (io/reader path)] 13 | (json/read r)))) 14 | 15 | #?(:clj 16 | (defmacro load-json-map 17 | "loads all names (being json files at base-path) into a 18 | map with the keyworded names for keys" 19 | [base-path names] 20 | (into {} 21 | (doall 22 | (for [n names 23 | :let [path (str base-path n ".json")]] 24 | [(keyword n) (with-open [r (io/reader path)] 25 | (json/read r))]))))) 26 | 27 | 28 | 29 | (def language-tag 30 | "en-US") 31 | 32 | (def language-map 33 | {language-tag "foo"}) 34 | 35 | (def iri 36 | "http://foo.com/bar") 37 | 38 | (def mailto 39 | "mailto:milt@yetanalytics.com") 40 | 41 | (def irl 42 | "www.foo.com") 43 | 44 | (def extensions 45 | {"http://www.foo.bar" {"arbitrary" "data"}}) 46 | 47 | (def openid 48 | iri) 49 | 50 | (def uuid-str 51 | "f47ac10b-58cc-4372-a567-0e02b2c3d479") 52 | 53 | (def timestamp 54 | "2014-09-10T14:12:05Z") 55 | 56 | (def duration 57 | "P3Y6M4DT12H30M5S") 58 | 59 | (def version 60 | "1.0.0") 61 | 62 | (def sha2 63 | "672fa5fa658017f1b72d65036f13379c6ab05d4ab3b6664908d8acf0b6a0c634") 64 | 65 | (def sha1 66 | "123") 67 | 68 | (def interaction-component 69 | {"id" "1" 70 | "description" {"en-US" "foo"}}) 71 | 72 | (def definition 73 | {"name" 74 | {"en-US" "simple statement"} 75 | "description" 76 | {"en-US" 77 | "A simple Experience API statement. Note that the LRS 78 | does not need to have any prior information about the Actor (learner), the 79 | verb, or the Activity/object."}}) 80 | 81 | (def definition-with-interaction-type 82 | {"name" 83 | {"en-US" "simple statement"} 84 | "description" 85 | {"en-US" 86 | "A simple Experience API statement. Note that the LRS 87 | does not need to have any prior information about the Actor (learner), the 88 | verb, or the Activity/object."} 89 | "interactionType" 90 | "other"}) 91 | 92 | (def activity 93 | {"id" iri 94 | "definition" definition}) 95 | 96 | (def account 97 | {"homePage" irl 98 | "name" "bob"}) 99 | 100 | (def agente 101 | {"mbox" mailto 102 | "objectType" "Agent"}) 103 | 104 | (def group 105 | {"mbox" mailto 106 | "objectType" "Group"}) 107 | 108 | (def anon-group 109 | {"objectType" "Group" 110 | "member" [agente]}) 111 | 112 | (def verb 113 | {"id" iri 114 | "display" language-map}) 115 | 116 | (def score 117 | {"max" 10 118 | "min" 1 119 | "raw" 5 120 | "scaled" 1.0}) 121 | 122 | (def result 123 | {"score" score 124 | "success" true 125 | "completion" true 126 | "response" "looking good!" 127 | "duration" duration 128 | "extensions" extensions}) 129 | 130 | (def statement-ref 131 | {"objectType" "StatementRef" 132 | "id" uuid-str}) 133 | 134 | (def context-activities 135 | {"parent" [activity] 136 | "grouping" [activity] 137 | "category" [activity] 138 | "other" [activity]}) 139 | 140 | (def context 141 | {"registration" uuid-str 142 | "instructor" agente 143 | "team" group 144 | "contextActivities" context-activities 145 | "revision" "whatever" 146 | "platform" "eeniac" 147 | "language" language-tag 148 | "statement" statement-ref 149 | "extensions" extensions}) 150 | 151 | (def attachment 152 | {"usageType" "http://foo.bar/baz" 153 | "display" language-map 154 | "contentType" "application/json" 155 | "length" 1024 156 | "sha2" sha2 157 | "fileUrl" "http://foo.bar/baz"}) 158 | 159 | (def sub-statement 160 | {"actor" agente 161 | "verb" verb 162 | "object" activity 163 | "objectType" "SubStatement"}) 164 | 165 | (def statement 166 | {"id" uuid-str 167 | "actor" agente 168 | "verb" verb 169 | "object" activity 170 | "timestamp" timestamp 171 | "stored" timestamp 172 | "authority" agente 173 | "version" version}) 174 | 175 | (def simple-statement 176 | (load-json "dev-resources/data/statements/simple.json")) 177 | 178 | (def long-statement 179 | (load-json "dev-resources/data/statements/long.json")) 180 | 181 | (def completion-statement 182 | (load-json "dev-resources/data/statements/completion.json")) 183 | 184 | (def void-statement 185 | (load-json "dev-resources/data/statements/void.json")) 186 | 187 | (def interaction-activity-defs 188 | (load-json-map 189 | "dev-resources/data/objects/activity/definition/interaction/" 190 | ["choice" "fill-in" "likert" "long-fill-in" 191 | "matching" "numeric" "other" "performance" 192 | "sequencing" "true-false"])) 193 | 194 | (def adl-sub-statement 195 | (load-json 196 | "dev-resources/data/objects/sub-statement.json")) 197 | 198 | (def authority-group 199 | (load-json 200 | "dev-resources/data/objects/authority.json")) 201 | -------------------------------------------------------------------------------- /README.org: -------------------------------------------------------------------------------- 1 | #+TITLE: xapi-schema 2 | #+AUTHOR: Milt Reder 3 | #+EMAIL: milt@yetanalytics.com 4 | 5 | [[https://github.com/yetanalytics/xapi-schema/actions/workflows/main.yml][https://github.com/yetanalytics/xapi-schema/actions/workflows/main.yml/badge.svg]] 6 | [[https://www.eclipse.org/legal/epl-v10.html][https://img.shields.io/badge/license-Eclipse-blue.svg]] 7 | [[https://clojars.org/com.yetanalytics/xapi-schema][https://img.shields.io/clojars/v/com.yetanalytics/xapi-schema.svg]] 8 | 9 | Clojure(script) schema for Experience API 1.0.3. Provides validation of Statements and other xAPI objects. 10 | 11 | ** Demo 12 | 13 | You can use xapi-schema to validate (and generate) statements in real-time [[http://yetanalytics.github.io/xapi-schema-demo/][with this demo]]. 14 | 15 | ** Getting Started 16 | 1. Add to your project dependencies: 17 | #+BEGIN_SRC clojure 18 | [[com.yetanalytics/xapi-schema "1.3.0"]] 19 | #+END_SRC 20 | 2. Require in your project: 21 | #+BEGIN_SRC clojure 22 | (ns your-project.core 23 | (:require [xapi-schema.core :as xs])) 24 | #+END_SRC 25 | 26 | ** Usage 27 | *** Clojure(script) 28 | **** Validate a Statement or Statements in edn 29 | #+BEGIN_SRC clojure 30 | (def statement 31 | {"id" "fd41c918-b88b-4b20-a0a5-a4c32391aaa0" 32 | "actor" {"objectType" "Agent" 33 | "name" "Project Tin Can API" 34 | "mbox" "mailto:user@example.com"} 35 | "verb" {"id" "http://example.com/xapi/verbs#sent-a-statement", 36 | "display" {"en-US" "sent"}} 37 | "object" {"id" "http://example.com/xapi/activity/simplestatement", 38 | "definition" 39 | {"name" {"en-US" "simple statement"} 40 | "description" 41 | {"en-US" "A simple Experience API statement. Note that the LRS 42 | does not need to have any prior information about the Actor (learner), the 43 | verb, or the Activity/object."}}}}) 44 | 45 | (xs/validate-statement-data statement) ;; => returns the statement 46 | 47 | (xs/validate-statement-data [stmt1 stmt2 stmt3]) ;; => returns the statements 48 | 49 | (let [bad-statement (dissoc statement "actor")] 50 | (xs/validate-statement-data bad-statement)) ;; => throws ExceptionInfo 51 | 52 | #+END_SRC 53 | 54 | **** Validate a Statement from JSON (Clojurescript) 55 | 56 | #+BEGIN_SRC clojure 57 | (let [json-statement (clj->js statement)] 58 | (xs/validate-statement-data-js json-statement)) ;; => returns the statement 59 | #+END_SRC 60 | 61 | **** Validate a Statement from a JSON string (Clojure(script)) 62 | 63 | #+BEGIN_SRC clojure 64 | (def statement-str 65 | "{\"object\":{\"id\":\"http://example.com/xapi/activity/simplestatement\", 66 | \"definition\":{\"name\":{\"en-US\":\"simple statement\"},\"description\": 67 | {\"en-US\":\"A simple Experience API statement. Note that the LRS\\n 68 | does not need to have any prior information about the Actor (learner), the\\n 69 | verb, or the Activity/object.\"}}},\"verb\":{\"id\":\"http://example.com/xapi 70 | /verbs#sent-a-statement\",\"display\":{\"en-US\":\"sent\"}},\"id\":\"fd41c918- 71 | b88b-4b20-a0a5-a4c32391aaa0\",\"actor\":{\"mbox\":\"mailto:user@example.com\" 72 | ,\"name\":\"Project Tin Can API\",\"objectType\":\"Agent\"}}") 73 | 74 | (xs/validate-statement-data statement-str) ;; => returns statement edn 75 | #+END_SRC 76 | 77 | **** 'Check' a Statement 78 | 79 | Checking a statement will return nil if it is valid, or a map of errors. 80 | 81 | #+BEGIN_SRC clojure 82 | (xs/statement-checker statement) ;; => nil 83 | (let [bad-statement (-> statement 84 | (dissoc "actor") 85 | (assoc "id" 123)] 86 | (xs/statement-checker bad-statement))) 87 | ;; => {:cljs.spec.alpha/problems (... 88 | #+END_SRC 89 | 90 | **** Use SubSchemata 91 | 92 | All of the subschemata in =xapi-schema.spec= are valid [[https://clojure.org/guides/spec][Clojure Specs]]: 93 | 94 | #+BEGIN_SRC clojure 95 | (ns your-project.core 96 | (:require [xapi-schema.core :as xs] 97 | [xapi-schema.spec :as json] 98 | [clojure.spec.alpha :as s])) 99 | (s/explain-data ::json/agent {"mbox" "mailto:bob@example.com"}) ;; => nil 100 | #+END_SRC 101 | 102 | **** Generate Statements 103 | 104 | You can use spec's generation functions to generate conformant statements containing random data: 105 | 106 | 1. Include the =test.check= dependency: 107 | #+BEGIN_SRC clojure 108 | [[com.yetanalytics/xapi-schema "1.0.0-alpha2"] 109 | [org.clojure/test.check "0.10.0-alpha2"]] 110 | #+END_SRC 111 | 2. Include the extra namespaces and generate! 112 | #+BEGIN_SRC clojure 113 | (ns your-project.core 114 | (:require [xapi-schema.spec :as xapispec] 115 | [clojure.spec.alpha :as s :include-macros true] 116 | [clojure.spec.gen.alpha :as sgen :include-macros true] 117 | clojure.test.check.generators)) 118 | (sgen/generate (s/gen ::xapispec/statement)) ;; => {"actor" {... 119 | #+END_SRC 120 | 121 | *** Plain ol' JavaScript 122 | 123 | If you want to use validations from JavaScript, first build the js: 124 | =$ lein do cljx, cljsbuild once release=. Then include the generated file, 125 | =target/js/xapi_schema.js= and invoke: 126 | 127 | #+BEGIN_SRC javascript 128 | var statement_str = '{"id":"fd41c918-b88b-4b20-a0a5-a4c32391aaa0", "actor":{"objectType": "Agent","name":"Project Tin Can API","mbox":"mailto:user@example.com"},"verb":{"id":"http://example.com/xapi/verbs#sent-a-statement","display":{ "en-US":"sent" }},"object":{"id":"http://example.com/xapi/activity/simplestatement","definition":{"name":{ "en-US":"simple statement" },"description":{ "en-US":"A simple Experience API statement. Note that the LRS does not need to have any prior information about the Actor (learner), the verb, or the Activity/object." }}}}'; 129 | var statement_json = JSON.parse(s); 130 | xapi_schema.core.validate_statement_data_js(statement_str); // => statement JSON 131 | xapi_schema.core.validate_statement_data_js(statement_json); // => statement JSON 132 | #+END_SRC 133 | 134 | ** Testing 135 | 136 | *** Clojure 137 | 138 | =$ make test-clj= 139 | 140 | *** ClojureScript 141 | 142 | =$ make test-cljs= 143 | 144 | *** Both 145 | 146 | =$ make ci= 147 | 148 | ** License 149 | 150 | Copyright © 2015-2024 Yet Analytics, Inc. 151 | 152 | Distributed under the Eclipse Public License, the same as Clojure. 153 | See the file [[file:LICENSE][LICENSE]] for details. 154 | -------------------------------------------------------------------------------- /src/xapi_schema/spec/regex.cljc: -------------------------------------------------------------------------------- 1 | (ns xapi-schema.spec.regex 2 | (:require [clojure.string :refer [join]])) 3 | 4 | (def LanguageTagRegEx ; RFC 5646, w/ lang subtag limitation 5 | (let [;; Language Subtags 6 | ;; Note: we exclude 4-8 char subtags, even though they are allowed in 7 | ;; the RFC spec, since they are reserved for future (not current) use. 8 | lang-tag "(?:[A-Za-z]{2,3})" 9 | lang-ext "(?:-[A-Za-z]{3})?" 10 | ;; Other Subtags 11 | script "(?:-[A-Za-z]{4})?" 12 | region "(?:-(?:[A-Za-z]{2}|\\d{3}))?" 13 | variant "(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*" 14 | ext "(?:-[A-WY-Za-wy-z0-9](?:-[A-Za-z0-9]{2,8})+)*" 15 | private "(?:-x(?:-[A-Za-z0-9]{1,8})+)?" 16 | ;; Grandfathered tags 17 | grand-irreg (str "(?:" 18 | "en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|" 19 | "i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|" 20 | "i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE" 21 | ")") 22 | grand-reg (str "(?:" 23 | "art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|" 24 | "zh-hakka|zh-min|zh-min-nan|zh-xiang" 25 | ")") 26 | ;; Tag 27 | tag (str "^(?:" 28 | "(?:" lang-tag lang-ext script region variant ext private ")" 29 | "|" grand-irreg 30 | "|" grand-reg 31 | ")$")] 32 | (re-pattern tag))) 33 | 34 | (defn- create-iri-regex ; Based on RFC 3897, with differences noted below 35 | [valid-schemes relative? unicode?] 36 | (let [;; Atoms 37 | ;; Note: RFC 3987 also specifies Unicode chars U+10000 to U+EFFFD, but 38 | ;; these are historic or obscure characters we'll never encounter. 39 | fs #?(:clj "\\/" :cljs "/") 40 | unicode-char (str "\\u00A0-\\uD7FF" "\\uF900-\\uFDCF" "\\uFDF0-\\uFFEF") 41 | unreserved (str "[\\w\\-\\.\\~" (when unicode? unicode-char) "]") 42 | pct-encoded "%[0-9A-Fa-f]{2}" 43 | sub-delims "[!$&'()*+,;=]" 44 | basic-char (str unreserved "|" pct-encoded "|" sub-delims) 45 | ;; Authority 46 | reg-name (str "(?:" basic-char ")*") 47 | user-info (str "(?:" basic-char "|:)*") 48 | host reg-name ; exclude IPv6 and subsume IPv4 49 | port "\\d*" 50 | authority (str "(?:" user-info "@)?(?:" host ")(?::" port ")?") 51 | ;; Path 52 | path-char (str basic-char "|:|@") 53 | segment (str "(?:" path-char ")*") 54 | segment-nz (str "(?:" path-char ")+") 55 | path-abempty (str "(?:" fs segment ")*") 56 | path-absolute (str "(?:" fs "(?:" segment-nz "(?:" fs segment ")*)?)") 57 | path-rootless (str "(?:" segment-nz "(?:" fs segment ")*)") 58 | ;; Misc 59 | scheme (if-not valid-schemes 60 | "[A-Za-z][0-9A-Za-z\\+\\-\\.]*" 61 | (str "(?:" (join "|" valid-schemes) ")")) 62 | query (str "(?:\\?(?:" path-char "|" fs "|" "\\?" ")*)") 63 | frag (str "(?:#(?:" path-char "|" fs "|" "\\?" ")*)") 64 | ;; Relative IRIs/URIs 65 | ;; Note: exclude non-absolute paths (ie. all paths must start with "/") 66 | relative (str "^(?:" path-absolute query "?" frag "?" ")$") 67 | ;; Absolute IRIs/URIs 68 | ;; Note: exclude empty paths 69 | abs-path (str "(?:" 70 | fs fs authority path-abempty "|" 71 | path-absolute "|" 72 | path-rootless 73 | ")") 74 | absolute (str "^(?:" scheme ":" abs-path query "?" frag "?" ")$")] 75 | (if relative? 76 | (re-pattern relative) 77 | (re-pattern absolute)))) 78 | 79 | (def OpenIdRegEx 80 | (create-iri-regex ["http" "https"] false false)) 81 | 82 | (def AbsoluteIRIRegEx 83 | (create-iri-regex nil false true)) 84 | 85 | (def RelativeIRLRegEx 86 | (create-iri-regex nil true true)) 87 | 88 | (def AbsoluteURIRegEx 89 | (create-iri-regex nil false false)) 90 | 91 | (def RelativeURLRegEx 92 | (create-iri-regex nil true false)) 93 | 94 | ;; Note: does not support Unicode characters despite being called "IRI" 95 | (def MailToIRIRegEx 96 | (let [username "(?:[\\w!#$&'*+/=/.?^`{|}~-]|%[0-9a-fA-F]{2})+" 97 | domain "(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\\.)+[a-zA-Z0-9]+"] 98 | (re-pattern (str "mailto:" username "@" domain)))) 99 | 100 | (def UuidRegEx ; RFC 3984 101 | (re-pattern (str "[0-9A-Fa-f]{8}-" ; [0-9A-Fa-f] = hex digit 102 | "[0-9A-Fa-f]{4}-" 103 | "[1-8][0-9A-Fa-f]{3}-" 104 | "[0-9A-Fa-f]{4}-" 105 | "[0-9A-Fa-f]{12}"))) 106 | 107 | (defn- base-timestamp [] 108 | (let [;; Date 109 | year "(\\d{4})" 110 | month "(0[1-9]|1[0-2])" 111 | day "(0[1-9]|[12]\\d|3[01])" ; ignore month/leap year constraints 112 | ;; Time 113 | hour "([01]\\d|2[0-3])" 114 | min "([0-5]\\d)" 115 | sec "([0-5]\\d|60)" ; leap seconds 116 | sec-frac "(\\.\\d+)" 117 | ;; Time 118 | time (str "(?:" hour ":" min ":" sec sec-frac "?" ")") 119 | date (str "(?:" year "-" month "-" day ")")] 120 | (str date "T" time))) 121 | 122 | (def TimestampRegEx ; RFC 3339 123 | (let [;; Time 124 | hour "(?:[01]\\d|2[0-3])" 125 | min "(?:[0-5]\\d)" 126 | ;; Offset 127 | lookahead "(?!-00:00)" 128 | num-offset (str "(?:[+-]" hour ":" min ")") 129 | time-offset (str "(Z|" lookahead num-offset ")")] 130 | (re-pattern (str "^" (base-timestamp) time-offset "$")))) 131 | 132 | (def DurationRegEx ; ISO 8601 Durations 133 | (let [dy "(?:\\d+Y|\\d+\\.\\d+Y$)" 134 | dm "(?:\\d+M|\\d+\\.\\d+M$)" 135 | dw "(?:\\d+W|\\d+\\.\\d+W$)" 136 | dd "(?:\\d+D|\\d+\\.\\d+D$)" 137 | dh "(?:\\d+H|\\d+\\.\\d+H$)" 138 | ds "(?:\\d+S|\\d+\\.\\d+S$)" 139 | dur-date (str "(?:" dd "|" dm dd "?" "|" dy dm "?" dd "?" ")") 140 | dur-time (str "(?:" ds "|" dm ds "?" "|" dh dm "?" ds "?" ")") 141 | dur-week (str "(?:" dw ")") 142 | duration (str "(?:" dur-date "(?:T" dur-time ")?" ")" "|" 143 | "(?:T" dur-time ")" "|" 144 | dur-week)] 145 | (re-pattern (str "^P(?:" duration ")|P(?:" (base-timestamp) ")$")))) 146 | 147 | (defn- base-timestamp-200 [] 148 | (let [;; Date 149 | year "(\\d{4})" 150 | month "(0[1-9]|1[0-2])" 151 | day "(0[1-9]|[12]\\d|3[01])" ; ignore month/leap year constraints 152 | ;; Time 153 | hour "([01]\\d|2[0-3])" 154 | min "([0-5]\\d)" 155 | sec "([0-5]\\d|60)" ; leap seconds 156 | sec-frac "(\\.\\d+)" 157 | ;; Time 158 | time (str "(?:" hour ":" min ":" sec sec-frac "?" ")") 159 | date (str "(?:" year "-" month "-" day ")")] 160 | (str date "[T\\s]" time))) 161 | 162 | (def TimestampRegEx200 ; RFC 3339 163 | (let [;; Time 164 | hour "(?:[01]\\d|2[0-3])" 165 | min "(?:[0-5]\\d)" 166 | ;; Offset 167 | lookahead "(?!-00:00)" 168 | num-offset (str "(?:[+-]" hour ":" min ")") 169 | time-offset (str "(Z|" lookahead num-offset ")")] 170 | (re-pattern (str "^" (base-timestamp-200) time-offset "$")))) 171 | 172 | (def DurationRegEx200 ; ISO 8601 Durations 173 | (let [dy "(?:\\d+Y|\\d+\\.\\d+Y$)" 174 | dm "(?:\\d+M|\\d+\\.\\d+M$)" 175 | dw "(?:\\d+W|\\d+\\.\\d+W$)" 176 | dd "(?:\\d+D|\\d+\\.\\d+D$)" 177 | dh "(?:\\d+H|\\d+\\.\\d+H$)" 178 | ds "(?:\\d+S|\\d+\\.\\d+S$)" 179 | dur-date (str "(?:" dd "|" dm dd "?" "|" dy dm "?" dd "?" ")") 180 | dur-time (str "(?:" ds "|" dm ds "?" "|" dh dm "?" ds "?" ")") 181 | dur-week (str "(?:" dw ")") 182 | duration (str "(?:" dur-date "(?:T" dur-time ")?" ")" "|" 183 | "(?:T" dur-time ")" "|" 184 | dur-week)] 185 | (re-pattern (str "^P(?:" duration ")|P(?:" (base-timestamp-200) ")$")))) 186 | 187 | 188 | ;; Based on http://www.regexr.com/39s32 189 | (def xAPIVersionRegEx 190 | (let [suf-part "[0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*" 191 | suffix (str "(\\.[0-9]+(?:-" suf-part ")?(?:\\+" suf-part ")?)?") 192 | ver-str (str "^1\\.0" suffix "$")] 193 | (re-pattern ver-str))) 194 | 195 | (def xAPIVersionRegEx200 196 | (let [suf-part "[0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*" 197 | suffix (str "(\\.[0-9]+(?:-" suf-part ")?(?:\\+" suf-part ")?)?") 198 | ver-str (str "^(1\\.0" suffix ")|(2\\.0\\.0)$")] 199 | (re-pattern ver-str))) 200 | 201 | (def Base64RegEx 202 | (let [fs #?(:clj "\\/" :cljs "/") 203 | body (str "(?:[A-Za-z0-9\\+" fs "]{4})*") 204 | suffix (str "(?:" 205 | "[A-Za-z0-9\\+" fs "]{2}==|" 206 | "[A-Za-z0-9\\+" fs "]{3}=|" 207 | "[A-Za-z0-9\\+" fs "]{4}" 208 | ")")] 209 | (re-pattern (str "^" body suffix "$")))) 210 | 211 | (def Sha1RegEx 212 | #"^[0-9a-fA-F]{40}$") 213 | 214 | (def Sha2RegEx 215 | #"^[0-9a-fA-F]{64}$") 216 | -------------------------------------------------------------------------------- /test/xapi_schema/spec/regex_test.cljc: -------------------------------------------------------------------------------- 1 | (ns xapi-schema.spec.regex-test 2 | (:require 3 | [clojure.test :refer [deftest is testing] :include-macros true] 4 | [xapi-schema.spec.regex :refer [LanguageTagRegEx 5 | AbsoluteIRIRegEx 6 | RelativeIRLRegEx 7 | AbsoluteURIRegEx 8 | RelativeURLRegEx 9 | MailToIRIRegEx 10 | UuidRegEx 11 | TimestampRegEx 12 | TimestampRegEx200 13 | xAPIVersionRegEx 14 | xAPIVersionRegEx200 15 | DurationRegEx 16 | Base64RegEx 17 | Sha1RegEx 18 | Sha2RegEx 19 | OpenIdRegEx]])) 20 | 21 | (deftest language-tag-regex-test 22 | (testing "matches valid Language Tags" 23 | (is (re-matches LanguageTagRegEx "en")) 24 | (is (re-matches LanguageTagRegEx "arb")) 25 | (is (re-matches LanguageTagRegEx "en-US")) ; lang + region 26 | (is (re-matches LanguageTagRegEx "es-419")) ; lang + region code 27 | (is (re-matches LanguageTagRegEx "zh-yue")) ; lang + extlang 28 | (is (re-matches LanguageTagRegEx "uz-Arab")) ; lang + script 29 | (is (re-matches LanguageTagRegEx "zh-cmn-Latn-CN")) ; lang + extension + region 30 | (is (re-matches LanguageTagRegEx "zh-Latn-CN-pinyin")) ; lang + script + region + variant 31 | (is (re-matches LanguageTagRegEx "de-DE-u-co-phonebk")) ; lang + region + extension 32 | (is (re-matches LanguageTagRegEx "en-US-x-twain")) ; lang + region + private 33 | (is (re-matches LanguageTagRegEx "sl-rozaj-biske")) ; lang + variant 34 | (is (re-matches LanguageTagRegEx "de-CH-1901")) ; lang + region + variant 35 | (is (re-matches LanguageTagRegEx "i-enochian")) ; grandfathered tag 36 | (is (re-matches LanguageTagRegEx "foo")) ; doesn't check if tag is registered 37 | (is (not (re-matches LanguageTagRegEx "not a language tag"))) 38 | (is (not (re-matches LanguageTagRegEx "en-"))) 39 | (is (not (re-matches LanguageTagRegEx "de-419-DE"))) ; two region tags 40 | (is (not (re-matches LanguageTagRegEx "a-DE"))) 41 | (is (not (re-matches LanguageTagRegEx "fr-u-o"))) 42 | (is (not (re-matches LanguageTagRegEx "americanenglish"))))) 43 | 44 | (deftest open-id-regex-test 45 | (testing "matches valid URIs" 46 | (is (re-matches OpenIdRegEx "http://www.foo.com")) 47 | (is (not (re-matches OpenIdRegEx "www.foo.com"))) 48 | (is (not (re-matches OpenIdRegEx "foo.com"))) 49 | (is (not (re-matches OpenIdRegEx "hey dude wat"))) 50 | (is (not (re-matches OpenIdRegEx "custom://www.foo.com")))) 51 | (testing "matches URIs with fragments" 52 | (is (re-matches OpenIdRegEx "http://example.com/xapi/verbs#sent-a-statement")))) 53 | 54 | (deftest absolute-iri-regex-test 55 | (testing "matches valid absolute IRIs" 56 | (is (re-matches AbsoluteIRIRegEx "http://foo.com")) 57 | (is (not (re-matches AbsoluteIRIRegEx "foo.com"))) 58 | (is (not (re-matches AbsoluteIRIRegEx "www.foo.com"))) 59 | ;; See issue 17 https://github.com/yetanalytics/xapi-schema/issues/17 60 | (is (re-matches AbsoluteIRIRegEx "foo:/a")) 61 | (is (re-matches AbsoluteIRIRegEx "foo+bar.baz-quxx:/a")) 62 | (is (re-matches AbsoluteIRIRegEx "reallydamnlongschemeoverhere://foo.bar"))) 63 | (testing "matches IRIs with fragments" 64 | (is (re-matches AbsoluteIRIRegEx "http://example.com/xapi/verbs#sent-a-statement")) 65 | (is (re-matches AbsoluteIRIRegEx "http://example.com/xapi/foo/#bar?my_jimmies=rustled")) 66 | (is (re-matches AbsoluteIRIRegEx "http://a_b/#foo")) 67 | (is (re-matches AbsoluteIRIRegEx "https://foo-baz.app.com/xapi/def/emb/qux*ROOT")) 68 | (is (re-matches AbsoluteIRIRegEx "https://foo-baz.app.com/xapi#foo:bar"))) 69 | (testing "matches IRIs with URL encodings" 70 | (is (re-matches AbsoluteIRIRegEx "http://cenariovr.com/174/Sharks!/sharks-Type%20of%20Shark")) 71 | (is (re-matches AbsoluteIRIRegEx "http://cenar%20iovr.com/1%2074/S%20harks!/shark%20s-Type%20of%20Shark"))) 72 | (testing "matches IRIs with Unicode characters" 73 | (is (re-matches AbsoluteIRIRegEx "https://en.wiktionary.org/wiki/Ῥόδος")) 74 | (is (re-matches AbsoluteIRIRegEx "https://www.你好世界.cn")) 75 | (is (re-matches AbsoluteIRIRegEx "https://www.example.kr#안녕하세요"))) 76 | (testing "matches IRIs with absolute and rootless paths" 77 | (is (re-matches AbsoluteIRIRegEx "https:/foo/bar")) 78 | (is (re-matches AbsoluteIRIRegEx "urn:example:animal:ferret:nose")))) 79 | 80 | (deftest relative-irl-regex-test 81 | (testing "matches valid relative IRLs" 82 | (is (re-matches RelativeIRLRegEx "/")) 83 | (is (re-matches RelativeIRLRegEx "/xapi")) 84 | (is (not (re-matches RelativeIRLRegEx "https://foo.com"))) 85 | (is (not (re-matches RelativeIRLRegEx "www.foo.com")))) 86 | (testing "matches relative IRLs with fragments" 87 | (is (re-matches RelativeIRLRegEx "/xapi/verbs#sent-a-statement")) 88 | (is (re-matches RelativeIRLRegEx "/xapi/foo/#bar?my_jimmies=rustled")) 89 | (is (re-matches RelativeIRLRegEx "/#foo")) 90 | (is (re-matches RelativeIRLRegEx "/xapi/def/emb/qux*ROOT")) 91 | (is (re-matches RelativeIRLRegEx "/xapi#foo:bar"))) 92 | (testing "matches relative IRLs with Unicode characters" 93 | (is (re-matches RelativeIRLRegEx "/wiki/Ῥόδος"))) 94 | (testing "does not match network path and no-scheme relative IRLs" 95 | (is (not (re-matches RelativeIRLRegEx "./this:that"))) 96 | (is (not (re-matches RelativeIRLRegEx "foo"))) 97 | (is (not (re-matches RelativeIRLRegEx "//foo.com/bar"))))) 98 | 99 | (deftest absolute-uri-regex-test 100 | (testing "matches valid absolute URIs" 101 | (is (re-matches AbsoluteURIRegEx "http://foo.com")) 102 | (is (re-matches AbsoluteURIRegEx "http://cenariovr.com/174/Sharks!/sharks-Type%20of%20Shark")) 103 | (is (not (re-matches AbsoluteURIRegEx "https://en.wiktionary.org/wiki/Ῥόδος"))))) 104 | 105 | (deftest absolute-url-regex-test 106 | (testing "matches valid relative URLs" 107 | (is (re-matches RelativeURLRegEx "/")) 108 | (is (re-matches RelativeURLRegEx "/#foo")) 109 | (is (not (re-matches RelativeURLRegEx "/wiki/Ῥόδος"))))) 110 | 111 | (deftest mailto-iri-regex-test 112 | (testing "matches valid mailto IRIs" 113 | (is (re-matches MailToIRIRegEx "mailto:milt@yetanalytics.com")) 114 | (is (not (re-matches MailToIRIRegEx "http://foo.com"))) 115 | (is (not (re-matches MailToIRIRegEx "milt@yetanalytics.com"))) 116 | (is (not (re-matches MailToIRIRegEx "mi%lt@yetanalytics.com"))) 117 | (is (re-matches MailToIRIRegEx "mailto:mi%0Alt@yetanalytics.com")) 118 | (is (re-matches MailToIRIRegEx "mailto:foo-baz.@some-domain.com")) 119 | ;; International email addresses are not yet supported 120 | (is (not (re-matches MailToIRIRegEx "mailto:你好世界@Ῥόδος.com"))))) 121 | 122 | (deftest uuid-regex-test 123 | (testing "matches valid 4.0 UUIDs" 124 | (is (re-matches UuidRegEx "f47ac10b-58cc-4372-a567-0e02b2c3d479")) 125 | (is (re-matches UuidRegEx "f47ac10b-58cc-4372-0567-0e02b2c3d479")) 126 | (is (not (re-matches UuidRegEx "1234-1234-1234-1234"))) 127 | (is (not (re-matches UuidRegEx "3c7db14d-ac4b-4e35-b2c6-3b2237f382"))) 128 | (is (not (re-matches UuidRegEx "MA97B177-9383-4934-8543-0F91A7A02836")))) 129 | (testing "matches all UUID versions" 130 | (is (re-matches UuidRegEx "f47ac10b-58cc-1372-0567-0e02b2c3d479")) 131 | (is (re-matches UuidRegEx "f47ac10b-58cc-2372-0567-0e02b2c3d479")) 132 | (is (re-matches UuidRegEx "f47ac10b-58cc-3372-0567-0e02b2c3d479")) 133 | (is (re-matches UuidRegEx "f47ac10b-58cc-4372-0567-0e02b2c3d479")) 134 | (is (re-matches UuidRegEx "f47ac10b-58cc-5372-0567-0e02b2c3d479")) 135 | (is (re-matches UuidRegEx "f47ac10b-58cc-6372-0567-0e02b2c3d479")) 136 | (is (re-matches UuidRegEx "f47ac10b-58cc-7372-0567-0e02b2c3d479")) 137 | (is (re-matches UuidRegEx "f47ac10b-58cc-8372-0567-0e02b2c3d479")))) 138 | 139 | (deftest timestamp-regex-test 140 | (testing "matches valid ISO 8601 datetime stamps within the rfc3339 profile" 141 | (is (re-matches TimestampRegEx "2015-05-13T15:16:00Z")) 142 | (is (re-matches TimestampRegEx "2015-05-13T15:16:00.304Z")) 143 | (is (re-matches TimestampRegEx "2015-05-13T15:16:00-20:00")) 144 | (is (re-matches TimestampRegEx "2016-11-22T16:50:25.3868080Z")) 145 | (is (re-matches TimestampRegEx "0003-06-04T12:30:05Z")) ; Duration example 146 | (is (not (re-matches TimestampRegEx "5-13-2015"))) 147 | (is (not (re-matches TimestampRegEx "20150513T15Z"))) 148 | (is (not (re-matches TimestampRegEx "20150513T15:16:00Z"))) 149 | ;; negative offset 150 | (is (not (re-matches TimestampRegEx "2008-09-15T15:53:00.601-00:00")))) 151 | (testing "matches valid but terrible stamps in rfc3339 OUTSIDE of 8601" 152 | (is (re-matches TimestampRegEx200 "2015-05-13 15:16:00Z")))) 153 | 154 | (deftest xapi-version-regex-test 155 | (testing "matches xAPI 1.0.X versions" 156 | (is (and (re-matches xAPIVersionRegEx "1.0.0") 157 | (re-matches xAPIVersionRegEx "1.0.2") 158 | (re-matches xAPIVersionRegEx "1.0") 159 | (re-matches xAPIVersionRegEx "1.0.32-abc.def+ghi.jkl"))) 160 | (is (not (re-matches xAPIVersionRegEx "0.9.5")))) 161 | (testing "matches xAPI 2.0.0 version only" 162 | (is (and (re-matches xAPIVersionRegEx200 "2.0.0") 163 | (not (re-matches xAPIVersionRegEx200 "2.0.2")))))) 164 | 165 | (deftest duration-regex-test 166 | (testing "matches ISO durations" 167 | (is (re-matches DurationRegEx "P3Y6M4DT12H30M5S")) 168 | (is (re-matches DurationRegEx "P23DT122.34S")) 169 | (is (re-matches DurationRegEx "PT3H0M25.51S")) 170 | (is (re-matches DurationRegEx "PT3H25.51S")) 171 | (is (re-matches DurationRegEx "P0003-06-04T12:30:05")) ; Wikipedia example 172 | (is (not (re-matches DurationRegEx "PT"))) 173 | (is (not (re-matches DurationRegEx "P10.3DT1.7S"))))) 174 | 175 | (deftest base64-regex-test 176 | (testing "matches Base64 encoded stuff" 177 | (is (re-matches 178 | Base64RegEx 179 | "495395e777cd98da653df9615d09c0fd6bb2f8d4788394cd53c56a3bfdcd848a")) 180 | (is (re-matches Base64RegEx "1234abcd")) 181 | (is (re-matches Base64RegEx "1234abc=")) 182 | (is (re-matches Base64RegEx "1234ab==")) 183 | (is (re-matches Base64RegEx "1234////")) 184 | (is (not (re-matches Base64RegEx "12345"))))) 185 | 186 | (deftest sha1-regex-test 187 | (testing "matches SHA-1 hashes" 188 | (is (re-matches Sha1RegEx "ebd31e95054c018b10727ccffd2ef2ec3a016ee9")) 189 | (is (not (re-matches Sha1RegEx "1234"))))) 190 | 191 | (deftest sha2-regex-test 192 | (testing "matches SHA-2 hashes" 193 | (is (re-matches Sha2RegEx "495395e777cd98da653df9615d09c0fd6bb2f8d4788394cd53c56a3bfdcd848a")) 194 | (is (not (re-matches Sha2RegEx "Q3lxN0R1NQ=="))))) 195 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC 2 | LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM 3 | CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 4 | 5 | 1. DEFINITIONS 6 | 7 | "Contribution" means: 8 | 9 | a) in the case of the initial Contributor, the initial code and 10 | documentation distributed under this Agreement, and 11 | 12 | b) in the case of each subsequent Contributor: 13 | 14 | i) changes to the Program, and 15 | 16 | ii) additions to the Program; 17 | 18 | where such changes and/or additions to the Program originate from and are 19 | distributed by that particular Contributor. A Contribution 'originates' from 20 | a Contributor if it was added to the Program by such Contributor itself or 21 | anyone acting on such Contributor's behalf. Contributions do not include 22 | additions to the Program which: (i) are separate modules of software 23 | distributed in conjunction with the Program under their own license 24 | agreement, and (ii) are not derivative works of the Program. 25 | 26 | "Contributor" means any person or entity that distributes the Program. 27 | 28 | "Licensed Patents" mean patent claims licensable by a Contributor which are 29 | necessarily infringed by the use or sale of its Contribution alone or when 30 | combined with the Program. 31 | 32 | "Program" means the Contributions distributed in accordance with this 33 | Agreement. 34 | 35 | "Recipient" means anyone who receives the Program under this Agreement, 36 | including all Contributors. 37 | 38 | 2. GRANT OF RIGHTS 39 | 40 | a) Subject to the terms of this Agreement, each Contributor hereby grants 41 | Recipient a non-exclusive, worldwide, royalty-free copyright license to 42 | reproduce, prepare derivative works of, publicly display, publicly perform, 43 | distribute and sublicense the Contribution of such Contributor, if any, and 44 | such derivative works, in source code and object code form. 45 | 46 | b) Subject to the terms of this Agreement, each Contributor hereby grants 47 | Recipient a non-exclusive, worldwide, royalty-free patent license under 48 | Licensed Patents to make, use, sell, offer to sell, import and otherwise 49 | transfer the Contribution of such Contributor, if any, in source code and 50 | object code form. This patent license shall apply to the combination of the 51 | Contribution and the Program if, at the time the Contribution is added by the 52 | Contributor, such addition of the Contribution causes such combination to be 53 | covered by the Licensed Patents. The patent license shall not apply to any 54 | other combinations which include the Contribution. No hardware per se is 55 | licensed hereunder. 56 | 57 | c) Recipient understands that although each Contributor grants the licenses 58 | to its Contributions set forth herein, no assurances are provided by any 59 | Contributor that the Program does not infringe the patent or other 60 | intellectual property rights of any other entity. Each Contributor disclaims 61 | any liability to Recipient for claims brought by any other entity based on 62 | infringement of intellectual property rights or otherwise. As a condition to 63 | exercising the rights and licenses granted hereunder, each Recipient hereby 64 | assumes sole responsibility to secure any other intellectual property rights 65 | needed, if any. For example, if a third party patent license is required to 66 | allow Recipient to distribute the Program, it is Recipient's responsibility 67 | to acquire that license before distributing the Program. 68 | 69 | d) Each Contributor represents that to its knowledge it has sufficient 70 | copyright rights in its Contribution, if any, to grant the copyright license 71 | set forth in this Agreement. 72 | 73 | 3. REQUIREMENTS 74 | 75 | A Contributor may choose to distribute the Program in object code form under 76 | its own license agreement, provided that: 77 | 78 | a) it complies with the terms and conditions of this Agreement; and 79 | 80 | b) its license agreement: 81 | 82 | i) effectively disclaims on behalf of all Contributors all warranties and 83 | conditions, express and implied, including warranties or conditions of title 84 | and non-infringement, and implied warranties or conditions of merchantability 85 | and fitness for a particular purpose; 86 | 87 | ii) effectively excludes on behalf of all Contributors all liability for 88 | damages, including direct, indirect, special, incidental and consequential 89 | damages, such as lost profits; 90 | 91 | iii) states that any provisions which differ from this Agreement are offered 92 | by that Contributor alone and not by any other party; and 93 | 94 | iv) states that source code for the Program is available from such 95 | Contributor, and informs licensees how to obtain it in a reasonable manner on 96 | or through a medium customarily used for software exchange. 97 | 98 | When the Program is made available in source code form: 99 | 100 | a) it must be made available under this Agreement; and 101 | 102 | b) a copy of this Agreement must be included with each copy of the Program. 103 | 104 | Contributors may not remove or alter any copyright notices contained within 105 | the Program. 106 | 107 | Each Contributor must identify itself as the originator of its Contribution, 108 | if any, in a manner that reasonably allows subsequent Recipients to identify 109 | the originator of the Contribution. 110 | 111 | 4. COMMERCIAL DISTRIBUTION 112 | 113 | Commercial distributors of software may accept certain responsibilities with 114 | respect to end users, business partners and the like. While this license is 115 | intended to facilitate the commercial use of the Program, the Contributor who 116 | includes the Program in a commercial product offering should do so in a 117 | manner which does not create potential liability for other Contributors. 118 | Therefore, if a Contributor includes the Program in a commercial product 119 | offering, such Contributor ("Commercial Contributor") hereby agrees to defend 120 | and indemnify every other Contributor ("Indemnified Contributor") against any 121 | losses, damages and costs (collectively "Losses") arising from claims, 122 | lawsuits and other legal actions brought by a third party against the 123 | Indemnified Contributor to the extent caused by the acts or omissions of such 124 | Commercial Contributor in connection with its distribution of the Program in 125 | a commercial product offering. The obligations in this section do not apply 126 | to any claims or Losses relating to any actual or alleged intellectual 127 | property infringement. In order to qualify, an Indemnified Contributor must: 128 | a) promptly notify the Commercial Contributor in writing of such claim, and 129 | b) allow the Commercial Contributor tocontrol, and cooperate with the 130 | Commercial Contributor in, the defense and any related settlement 131 | negotiations. The Indemnified Contributor may participate in any such claim 132 | at its own expense. 133 | 134 | For example, a Contributor might include the Program in a commercial product 135 | offering, Product X. That Contributor is then a Commercial Contributor. If 136 | that Commercial Contributor then makes performance claims, or offers 137 | warranties related to Product X, those performance claims and warranties are 138 | such Commercial Contributor's responsibility alone. Under this section, the 139 | Commercial Contributor would have to defend claims against the other 140 | Contributors related to those performance claims and warranties, and if a 141 | court requires any other Contributor to pay any damages as a result, the 142 | Commercial Contributor must pay those damages. 143 | 144 | 5. NO WARRANTY 145 | 146 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON 147 | AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER 148 | EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR 149 | CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A 150 | PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the 151 | appropriateness of using and distributing the Program and assumes all risks 152 | associated with its exercise of rights under this Agreement , including but 153 | not limited to the risks and costs of program errors, compliance with 154 | applicable laws, damage to or loss of data, programs or equipment, and 155 | unavailability or interruption of operations. 156 | 157 | 6. DISCLAIMER OF LIABILITY 158 | 159 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY 160 | CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, 161 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION 162 | LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 163 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 164 | ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE 165 | EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY 166 | OF SUCH DAMAGES. 167 | 168 | 7. GENERAL 169 | 170 | If any provision of this Agreement is invalid or unenforceable under 171 | applicable law, it shall not affect the validity or enforceability of the 172 | remainder of the terms of this Agreement, and without further action by the 173 | parties hereto, such provision shall be reformed to the minimum extent 174 | necessary to make such provision valid and enforceable. 175 | 176 | If Recipient institutes patent litigation against any entity (including a 177 | cross-claim or counterclaim in a lawsuit) alleging that the Program itself 178 | (excluding combinations of the Program with other software or hardware) 179 | infringes such Recipient's patent(s), then such Recipient's rights granted 180 | under Section 2(b) shall terminate as of the date such litigation is filed. 181 | 182 | All Recipient's rights under this Agreement shall terminate if it fails to 183 | comply with any of the material terms or conditions of this Agreement and 184 | does not cure such failure in a reasonable period of time after becoming 185 | aware of such noncompliance. If all Recipient's rights under this Agreement 186 | terminate, Recipient agrees to cease use and distribution of the Program as 187 | soon as reasonably practicable. However, Recipient's obligations under this 188 | Agreement and any licenses granted by Recipient relating to the Program shall 189 | continue and survive. 190 | 191 | Everyone is permitted to copy and distribute copies of this Agreement, but in 192 | order to avoid inconsistency the Agreement is copyrighted and may only be 193 | modified in the following manner. The Agreement Steward reserves the right to 194 | publish new versions (including revisions) of this Agreement from time to 195 | time. No one other than the Agreement Steward has the right to modify this 196 | Agreement. The Eclipse Foundation is the initial Agreement Steward. The 197 | Eclipse Foundation may assign the responsibility to serve as the Agreement 198 | Steward to a suitable separate entity. Each new version of the Agreement will 199 | be given a distinguishing version number. The Program (including 200 | Contributions) may always be distributed subject to the version of the 201 | Agreement under which it was received. In addition, after a new version of 202 | the Agreement is published, Contributor may elect to distribute the Program 203 | (including its Contributions) under the new version. Except as expressly 204 | stated in Sections 2(a) and 2(b) above, Recipient receives no rights or 205 | licenses to the intellectual property of any Contributor under this 206 | Agreement, whether expressly, by implication, estoppel or otherwise. All 207 | rights in the Program not expressly granted under this Agreement are 208 | reserved. 209 | 210 | This Agreement is governed by the laws of the State of New York and the 211 | intellectual property laws of the United States of America. No party to this 212 | Agreement will bring a legal action under this Agreement more than one year 213 | after the cause of action arose. Each party waives its rights to a jury trial 214 | in any resulting litigation. 215 | -------------------------------------------------------------------------------- /src/xapi_schema/spec/resources.cljc: -------------------------------------------------------------------------------- 1 | (ns xapi-schema.spec.resources 2 | (:require 3 | [xapi-schema.spec :as xs :include-macros true] 4 | [xapi-schema.spec.util :as util :include-macros true] 5 | [clojure.spec.alpha :as s #?@(:cljs [:include-macros true])] 6 | [clojure.spec.gen.alpha :as sgen :include-macros true] 7 | [clojure.walk :as walk] 8 | #?@(:clj [[clojure.data.json :as json]])) 9 | #?(:cljs (:require-macros [xapi-schema.spec.resources :refer [json]]))) 10 | 11 | (def ^:dynamic *read-json-fn* 12 | #?(:clj json/read-str 13 | :cljs (fn [s] (js->clj (.parse js/JSON s))))) 14 | 15 | (def ^:dynamic *write-json-fn* 16 | #?(:clj json/write-str 17 | :cljs (fn [data] (.stringify js/JSON (clj->js data))))) 18 | 19 | #?(:clj 20 | (defmacro with-json 21 | "Bind alternative json read/write fns" 22 | [{:keys [read-fn write-fn] 23 | :or {read-fn *read-json-fn* 24 | write-fn *write-json-fn*}} & body] 25 | `(binding [*read-json-fn* ~read-fn 26 | *write-json-fn* ~write-fn] 27 | ~@body))) 28 | 29 | (defn conform-json [s] 30 | (if (string? s) 31 | (if (not-empty s) 32 | (try (*read-json-fn* ^String s) 33 | (catch #?(:clj java.lang.Exception 34 | :cljs js/Error) _ 35 | ::s/invalid)) 36 | ::s/invalid) 37 | s)) 38 | 39 | (defn unform-json ^String [data] 40 | (if (string? data) 41 | data 42 | (try (*write-json-fn* data) 43 | (catch #?(:clj java.lang.Exception 44 | :cljs js/Error) _ 45 | ::s/invalid)))) 46 | 47 | (def json-string-conformer 48 | (s/conformer 49 | conform-json 50 | unform-json)) 51 | 52 | #?(:clj 53 | (defmacro json 54 | [spec] 55 | `(util/with-conformer 56 | ~spec conform-json unform-json))) 57 | 58 | ;; xAPI Resources 59 | 60 | ;; common 61 | (s/def :xapi.common.param/agent 62 | (json 63 | (s/nonconforming ::xs/actor))) 64 | 65 | ;; Statements 66 | ;; GET https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Communication.md#213-get-statements 67 | (s/def :xapi.statements.GET.request.params/statementId 68 | :statement/id) 69 | 70 | (s/def :xapi.statements.GET.request.params/voidedStatementId 71 | :statement/id) 72 | 73 | (s/def :xapi.statements.GET.request.params/agent 74 | :xapi.common.param/agent) 75 | 76 | (s/def :xapi.statements.GET.request.params/verb 77 | ::xs/iri) 78 | 79 | (s/def :xapi.statements.GET.request.params/activity 80 | ::xs/iri) 81 | 82 | (s/def :xapi.statements.GET.request.params/registration 83 | ::xs/uuid) 84 | 85 | (s/def :xapi.statements.GET.request.params/related_activities 86 | (json boolean?)) 87 | 88 | (s/def :xapi.statements.GET.request.params/related_agents 89 | (json boolean?)) 90 | 91 | (s/def :xapi.statements.GET.request.params/since 92 | ::xs/timestamp) 93 | 94 | (s/def :xapi.statements.GET.request.params/until 95 | ::xs/timestamp) 96 | 97 | (s/def :xapi.statements.GET.request.params/limit 98 | (json 99 | (s/int-in 0 #?(:clj Long/MAX_VALUE 100 | :cljs (.-MAX_VALUE js/Number))))) 101 | 102 | (s/def :xapi.statements.GET.request.params/format 103 | #{"ids" "exact" "canonical"}) 104 | 105 | (s/def :xapi.statements.GET.request.params/attachments 106 | (json boolean?)) 107 | 108 | (s/def :xapi.statements.GET.request.params/ascending 109 | (json boolean?)) 110 | 111 | ;; 112 | 113 | (def singular-query? 114 | (comp 115 | some? 116 | (some-fn :statementId 117 | :voidedStatementId))) 118 | 119 | (defmulti query-type 120 | #(if (singular-query? %) 121 | :xapi.statements.GET.request.params/singular 122 | :xapi.statements.GET.request.params/multiple)) 123 | 124 | (def statements-query-singular-spec 125 | (s/keys :req-un [(or :xapi.statements.GET.request.params/statementId 126 | :xapi.statements.GET.request.params/voidedStatementId)] 127 | :opt-un [:xapi.statements.GET.request.params/format 128 | :xapi.statements.GET.request.params/attachments])) 129 | 130 | (defmethod query-type :xapi.statements.GET.request.params/singular [_] 131 | (s/with-gen statements-query-singular-spec 132 | ;; spec generates these with both of the required keys, which is weird. 133 | ;; Force it to be only one! 134 | (fn [] 135 | (sgen/fmap (fn [params] 136 | (dissoc params 137 | (rand-nth [:statementId :voidedStatementId]))) 138 | (s/gen statements-query-singular-spec))))) 139 | 140 | (defmethod query-type :xapi.statements.GET.request.params/multiple [_] 141 | (s/keys :opt-un [:xapi.statements.GET.request.params/agent 142 | :xapi.statements.GET.request.params/verb 143 | :xapi.statements.GET.request.params/activity 144 | :xapi.statements.GET.request.params/registration 145 | :xapi.statements.GET.request.params/related_activities 146 | :xapi.statements.GET.request.params/related_agents 147 | :xapi.statements.GET.request.params/since 148 | :xapi.statements.GET.request.params/until 149 | :xapi.statements.GET.request.params/limit 150 | :xapi.statements.GET.request.params/format 151 | :xapi.statements.GET.request.params/attachments 152 | :xapi.statements.GET.request.params/ascending])) 153 | 154 | 155 | (s/def :xapi.statements.GET.request/params 156 | (s/multi-spec query-type (fn [gen-val _] 157 | gen-val))) 158 | 159 | (s/def :xapi.statements.PUT.request.params/statementId 160 | :statement/id) 161 | 162 | (s/def :xapi.statements.PUT.request/params 163 | (s/keys :req-un [:xapi.statements.PUT.request.params/statementId])) 164 | 165 | ;; StatementResult https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#retrieval 166 | (s/def :xapi.statements.GET.response.statement-result/statements 167 | (s/coll-of (s/nonconforming ::xs/statement) :into [])) 168 | 169 | (s/def :xapi.statements.GET.response.statement-result/more 170 | ::xs/relative-irl) 171 | 172 | (s/def :xapi.statements.GET.response/statement-result 173 | (s/keys :req-un [:xapi.statements.GET.response.statement-result/statements] 174 | :opt-un [:xapi.statements.GET.response.statement-result/more])) 175 | 176 | ;; Document Resources https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Communication.md#22-document-resources 177 | 178 | (def document-id 179 | (s/with-gen (s/and string? 180 | not-empty) 181 | (fn [] 182 | (sgen/not-empty 183 | (sgen/string-ascii))))) 184 | 185 | ;; ID 186 | (s/def :xapi.document.params/stateId 187 | document-id) 188 | 189 | (s/def :xapi.document.params/profileId 190 | document-id) 191 | 192 | ;; Context 193 | (s/def :xapi.document.params/activityId 194 | :activity/id) 195 | 196 | (s/def :xapi.document.params/agent 197 | (json 198 | (s/nonconforming ::xs/agent))) 199 | 200 | (s/def :xapi.document.params/registration 201 | ::xs/uuid) 202 | 203 | ;; Query 204 | 205 | (s/def :xapi.document.params/since 206 | ::xs/timestamp) 207 | 208 | ;; State https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Communication.md#23-state-resource 209 | 210 | (s/def :xapi.document.state/context-params 211 | (s/keys :req-un [:xapi.document.params/activityId 212 | :xapi.document.params/agent] 213 | :opt-un [:xapi.document.params/registration])) 214 | 215 | ;; Params for methods that work on a single state doc 216 | (s/def :xapi.document.state/id-params 217 | (s/keys :req-un [:xapi.document.params/stateId 218 | :xapi.document.params/activityId 219 | :xapi.document.params/agent] 220 | :opt-un [:xapi.document.params/registration])) 221 | 222 | (s/def :xapi.document.state/query-params 223 | (s/keys :req-un [:xapi.document.params/activityId 224 | :xapi.document.params/agent] 225 | :opt-un [:xapi.document.params/registration 226 | :xapi.document.params/since])) 227 | 228 | ;; Routes + Methods 229 | 230 | (s/def :xapi.activities.state.PUT.request/params 231 | :xapi.document.state/id-params) 232 | 233 | (s/def :xapi.activities.state.POST.request/params 234 | :xapi.document.state/id-params) 235 | 236 | (s/def :xapi.activities.state.GET.request/params 237 | (s/or 238 | :id 239 | :xapi.document.state/id-params 240 | :query 241 | :xapi.document.state/query-params)) 242 | 243 | (s/def :xapi.activities.state.DELETE.request/params 244 | (s/or 245 | :id 246 | :xapi.document.state/id-params 247 | :context 248 | :xapi.document.state/context-params)) 249 | 250 | ;; Agents https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Communication.md#24-agents-resource 251 | (s/def :xapi.agents.GET.request.params/agent 252 | :xapi.document.params/agent) 253 | 254 | (s/def :xapi.agents.GET.request/params 255 | (s/keys :req-un [:xapi.agents.GET.request.params/agent])) 256 | 257 | ;; Person https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Communication.md#person-properties 258 | (s/def :xapi.agents.GET.response.person/objectType 259 | #{"Person"}) 260 | 261 | (s/def :xapi.agents.GET.response.person/name 262 | (s/coll-of string? :kind vector? :into [])) 263 | 264 | (s/def :xapi.agents.GET.response.person/mbox 265 | (s/coll-of ::xs/mailto-iri :kind vector? :into [])) 266 | 267 | (s/def :xapi.agents.GET.response.person/mbox_sha1sum 268 | (s/coll-of ::xs/sha1sum :kind vector? :into [])) 269 | 270 | (s/def :xapi.agents.GET.response.person/openid 271 | (s/coll-of ::xs/openid :kind vector? :into [])) 272 | 273 | (s/def :xapi.agents.GET.response.person/account 274 | (s/coll-of ::xs/account :kind vector? :into [])) 275 | 276 | (s/def :xapi.agents.GET.response/person 277 | (s/nonconforming 278 | (util/with-conformer 279 | (s/keys :req-un [:xapi.agents.GET.response.person/objectType] 280 | :opt-un [:xapi.agents.GET.response.person/name 281 | :xapi.agents.GET.response.person/mbox 282 | :xapi.agents.GET.response.person/mbox_sha1sum 283 | :xapi.agents.GET.response.person/openid 284 | :xapi.agents.GET.response.person/account 285 | ]) 286 | walk/keywordize-keys 287 | walk/stringify-keys))) 288 | 289 | ;; Activities Resource https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Communication.md#25-activities-resource 290 | 291 | (s/def :xapi.activities.GET.request.params/activityId 292 | ::xs/iri) 293 | 294 | (s/def :xapi.activities.GET.request/params 295 | (s/keys :req-un [:xapi.activities.GET.request.params/activityId])) 296 | 297 | ;; Agent Profile https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Communication.md#26-agent-profile-resource 298 | (s/def :xapi.document.agent-profile/context-params 299 | (s/keys :req-un [:xapi.document.params/agent])) 300 | 301 | ;; Params for methods that work on a single state doc 302 | (s/def :xapi.document.agent-profile/id-params 303 | (s/keys :req-un [:xapi.document.params/agent 304 | :xapi.document.params/profileId])) 305 | 306 | (s/def :xapi.document.agent-profile/query-params 307 | (s/keys :req-un [:xapi.document.params/agent] 308 | :opt-un [:xapi.document.params/since])) 309 | 310 | ;; Routes + Methods 311 | 312 | (s/def :xapi.agents.profile.PUT.request/params 313 | :xapi.document.agent-profile/id-params) 314 | 315 | (s/def :xapi.agents.profile.POST.request/params 316 | :xapi.document.agent-profile/id-params) 317 | 318 | (s/def :xapi.agents.profile.GET.request/params 319 | (s/or 320 | :id 321 | :xapi.document.agent-profile/id-params 322 | :query 323 | :xapi.document.agent-profile/query-params)) 324 | 325 | (s/def :xapi.agents.profile.DELETE.request/params 326 | :xapi.document.agent-profile/id-params) 327 | 328 | ;; Activity Profile https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Communication.md#27-activity-profile-resource 329 | 330 | (s/def :xapi.document.activity-profile/context-params 331 | (s/keys :req-un [:xapi.document.params/activityId])) 332 | 333 | ;; Params for methods that work on a single activity-profile doc 334 | (s/def :xapi.document.activity-profile/id-params 335 | (s/keys :req-un [:xapi.document.params/activityId 336 | :xapi.document.params/profileId])) 337 | 338 | (s/def :xapi.document.activity-profile/query-params 339 | (s/keys 340 | :req-un [:xapi.document.params/activityId] 341 | :opt-un [:xapi.document.params/since])) 342 | 343 | ;; Routes + Methods 344 | 345 | (s/def :xapi.activities.profile.PUT.request/params 346 | :xapi.document.activity-profile/id-params) 347 | 348 | (s/def :xapi.activities.profile.POST.request/params 349 | :xapi.document.activity-profile/id-params) 350 | 351 | (s/def :xapi.activities.profile.GET.request/params 352 | (s/or 353 | :id 354 | :xapi.document.activity-profile/id-params 355 | :query 356 | :xapi.document.activity-profile/query-params)) 357 | 358 | (s/def :xapi.activities.profile.DELETE.request/params 359 | :xapi.document.activity-profile/id-params) 360 | 361 | ;; Abstract Document Params 362 | ;; useful for conforming 363 | (s/def :xapi.document.generic/params 364 | (s/or :state 365 | (s/or :id 366 | :xapi.document.state/id-params 367 | :context 368 | :xapi.document.state/context-params 369 | :query 370 | :xapi.document.state/query-params) 371 | :agent-profile 372 | (s/or :id 373 | :xapi.document.agent-profile/id-params 374 | :context 375 | :xapi.document.agent-profile/context-params 376 | :query 377 | :xapi.document.agent-profile/query-params) 378 | :activity-profile 379 | (s/or :id 380 | :xapi.document.activity-profile/id-params 381 | :context 382 | :xapi.document.activity-profile/context-params 383 | :query 384 | :xapi.document.activity-profile/query-params))) 385 | 386 | ;; About https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Communication.md#28-about-resource 387 | 388 | (s/def :xapi.about.GET.response.body/version 389 | (s/coll-of ::xs/version :kind vector? :into [])) 390 | 391 | (s/def :xapi.about.GET.response.body/extensions 392 | ::xs/extensions) 393 | 394 | (s/def :xapi.about.GET.response/body 395 | (s/keys :req-un [:xapi.about.GET.response.body/version] 396 | :opt-un [:xapi.about.GET.response.body/extensions])) 397 | -------------------------------------------------------------------------------- /test/xapi_schema/spec_test.cljc: -------------------------------------------------------------------------------- 1 | (ns xapi-schema.spec-test 2 | (:require [clojure.test :refer [deftest is testing] :include-macros true] 3 | [clojure.spec.alpha :as s :include-macros true] 4 | [xapi-schema.spec :as xs :include-macros true] 5 | [xapi-schema.support.spec :refer [should-satisfy 6 | should-not-satisfy 7 | should-satisfy+ 8 | key-should-satisfy+]] 9 | [xapi-schema.support.data :as d :refer [simple-statement 10 | long-statement]])) 11 | 12 | (deftest double-conformer-test 13 | (testing "conforms to double" 14 | (is (= 1.0 15 | (s/conform xs/double-conformer 1)))) 16 | (testing "unform is a no-op" 17 | (is (= 1.0 18 | (->> 1 19 | (s/conform xs/double-conformer) 20 | (s/unform xs/double-conformer)))))) 21 | 22 | (deftest conform-unform-test 23 | (is (= long-statement 24 | (s/unform ::xs/statement (s/conform ::xs/statement long-statement))))) 25 | 26 | (deftest conform-ns-map-test 27 | (is (= (name (xs/conform-ns-map "foo/bar" [])) 28 | "invalid"))) 29 | 30 | (deftest language-tag-test 31 | (testing "is a valid RFC 5646 Language Tag" 32 | (should-satisfy+ ::xs/language-tag 33 | "en-US" 34 | :bad 35 | "not a tag!"))) 36 | 37 | (deftest language-map-test 38 | (testing "has LanguageTags for keys" 39 | (should-satisfy+ ::xs/language-map 40 | {"en-US" "foo"} 41 | {"es" "hola mundo"} 42 | {"zh-cmn" "你好世界"} 43 | {} ; empty lang maps are allowed by spec 44 | :bad 45 | {"hey there" "foo"} 46 | {"en" 2}))) 47 | 48 | (deftest iri-test 49 | (testing "must be a valid url with scheme" 50 | (should-satisfy+ ::xs/iri 51 | "http://foo.com" 52 | :bad 53 | "foo.com"))) 54 | 55 | (deftest mail-to-iri-test 56 | (testing "must be a valid foaf mbox" 57 | (should-satisfy+ ::xs/mailto-iri 58 | "mailto:milt@yetanalytics.com" 59 | :bad 60 | "mailto:user%@example.com" 61 | "milt@yetanalytics.com"))) 62 | 63 | (deftest irl-test 64 | (testing "must be a valid URL" 65 | (should-satisfy+ ::xs/irl 66 | "http://foo.com" 67 | :bad 68 | "not an IRL"))) 69 | 70 | (deftest extensions-test 71 | (testing "is a map with IRI keys" 72 | (should-satisfy+ ::xs/extensions 73 | {"http://www.foo.bar" {"arbitrary" "data"}} 74 | :bad 75 | {"foo.bar" {"arbitrary" "data"}}))) 76 | 77 | (deftest open-id-test 78 | (testing "is a valid URL" 79 | (should-satisfy+ ::xs/openid 80 | "http://foo.bar/baz" 81 | :bad 82 | "some other crap"))) 83 | 84 | (deftest uuid-test 85 | (testing "is a valid v1-8 UUID" 86 | (should-satisfy+ ::xs/uuid 87 | "f47ac10b-58cc-4372-a567-0e02b2c3d479" 88 | "12345678-1234-1234-1234-123456789012" 89 | "017b4f9f-2a7e-84f1-80e9-7b788a5baba4" 90 | :bad 91 | ;; 9 is not a valid version number 92 | "12345678-1234-9234-1234-123456789012"))) 93 | 94 | (deftest timestamp-test 95 | (testing "is a valid ISO 8601 DateTime" 96 | (should-satisfy+ ::xs/timestamp 97 | "2014-09-10T14:12:05Z" 98 | "2014-10-31T14:12:05Z" ; october 31 99 | "2015-06-30T23:59:60Z" ; leap second 100 | "2020-02-29T01:01:01Z" ; leap year 101 | "2000-02-29T01:01:01Z" ; 2000 is a leap year 102 | :bad 103 | "09-10-2014T14:12:00+500" 104 | "2014-09-32T14:12:05Z" ; september 32 105 | "2014-09-31T14:12:05Z" ; september 31 106 | "2014-10-32T14:12:05Z" ; october 32 107 | "2014-09-12T03:47:40" ; no time zone 108 | "2021-02-30T01:01:01Z" ; february 30 109 | "2021-02-29T01:01:01Z" ; february 29, non leap year 110 | "1900-02-29T01:01:01Z" ; 1900 is not a leap year 111 | "2014-09-10T14:12:05.22.33Z"))) 112 | 113 | (deftest duration-test 114 | (testing "is a valid ISO 8601 Duration" 115 | (should-satisfy+ ::xs/duration 116 | "P3Y6M4DT12H30M5S" 117 | "P3Y6M4DT12H30M5.2S" ;; good fractional 118 | :bad 119 | "2 hours" 120 | "P" 121 | "PT" 122 | "P3Y6M4DT12H30.1M5S"))) ;; bad fractional 123 | 124 | 125 | (deftest version-test 126 | (testing "is a valid xAPI 1.0.X version" 127 | (should-satisfy+ ::xs/version 128 | "1.0.0" 129 | "1.0.1" 130 | "1.0.2" 131 | "1.0.3" 132 | "1.0.3-rc1" 133 | "1.0.0-alpha" 134 | "1.0.0-alpha.1" 135 | "1.0.0-0.3.7" 136 | "1.0.0-x.7.z.92" 137 | :bad 138 | "0.9.5" 139 | "1.0." ;; bad semver 140 | "1.0.0-.123" 141 | "1.0.0-..." 142 | "1.0.0-123." 143 | "1.0.0-+" 144 | "1.0.0-+123" 145 | "1.0.0-" 146 | "what's going on?"))) 147 | 148 | (deftest sha2-test 149 | (testing "is a Base64 encoded string" 150 | (should-satisfy+ ::xs/sha2 151 | "672fa5fa658017f1b72d65036f13379c6ab05d4ab3b6664908d8acf0b6a0c634" 152 | :bad 153 | 123 154 | "Q3lxN0R1NQ=="))) 155 | 156 | (deftest sha1sum-test 157 | (testing "is a SHA-1 string of 40 hex chars" 158 | (should-satisfy+ 159 | ::xs/sha1sum 160 | "2fd4e1c67a2d28fced849ee1bb76e7391b93eb12" 161 | :bad 162 | "2fd4e1c67a2d28fced849ee1bb76e7391" ;; >40 163 | "12345" 164 | "wat" 165 | 2))) 166 | 167 | (deftest interaction-component-test 168 | (testing "must have an ID" 169 | (should-satisfy+ ::xs/interaction-component 170 | {"id" "foo"} 171 | :bad 172 | {}))) 173 | 174 | (deftest interaction-components-test 175 | (testing 176 | "each component" 177 | (testing "must have a unique ID" 178 | (should-satisfy+ ::xs/interaction-components 179 | [{"id" "1" 180 | "description" {"en-US" "foo"}} 181 | {"id" "2" 182 | "description" {"en-US" "bar"}}] 183 | :bad 184 | [{"id" "1" 185 | "description" {"en-US" "foo"}} 186 | {"id" "1" 187 | "description" {"en-US" "bar"}}])))) 188 | 189 | (deftest definition-test 190 | (let [definition d/definition 191 | definition-with-interaction-type d/definition-with-interaction-type] 192 | (testing "should be satisfied by a valid definition" 193 | (should-satisfy+ :activity/definition 194 | definition)) 195 | (testing 196 | "correctResponsesPattern" 197 | (testing "is an array of strings" 198 | (key-should-satisfy+ 199 | :activity/definition 200 | definition-with-interaction-type 201 | "correctResponsesPattern" 202 | ["foo" "bar" "baz"] 203 | :bad 204 | "foo bar baz"))) 205 | (testing 206 | "interactionType" 207 | (testing "is one of true-false, choice, fill-in, long-fill-in, matching, 208 | performance, sequencing, likert, numeric, other" 209 | (key-should-satisfy+ :activity/definition 210 | definition 211 | "interactionType" 212 | "true-false" "choice" "fill-in" "long-fill-in" "matching" 213 | "performance" "sequencing" "likert" "numeric" "other" 214 | :bad "foo"))) 215 | 216 | (testing "when the activity is an interaction activity" 217 | (testing "is satisfied by all interaction types" 218 | (apply should-satisfy+ :activity/definition 219 | (vals d/interaction-activity-defs)))))) 220 | 221 | (deftest activity-test 222 | (testing 223 | "id" 224 | (testing "is required" 225 | (should-satisfy+ ::xs/activity 226 | {"id" "http://foo.com/bar"} 227 | :bad 228 | {}))) 229 | (testing 230 | "objectType" 231 | (testing "must be Activity if present" 232 | (should-satisfy+ ::xs/activity 233 | {"id" "http://foo.com/bar"} 234 | {"id" "http://foo.com/bar" 235 | "objectType" "Activity"} 236 | :bad 237 | {"id" "http://foo.com/bar" 238 | "objectType" "foo"})))) 239 | 240 | (deftest account-test 241 | (testing 242 | "name" 243 | (testing "is required" 244 | (should-satisfy+ ::xs/account 245 | {"name" "bob" 246 | "homePage" "http://foo.com/bar"} 247 | :bad 248 | {"homePage" "http://foo.com/bar"}))) 249 | (testing 250 | "homePage" 251 | (testing "is required" 252 | (should-satisfy+ ::xs/account 253 | {"name" "bob" 254 | "homePage" "http://foo.com/bar"} 255 | :bad 256 | {"name" "bob"})))) 257 | 258 | (deftest agent-test 259 | (testing "must have one and only one IFI" 260 | (should-satisfy+ ::xs/agent 261 | {"mbox" "mailto:milt@yetanalytics.com"} 262 | :bad 263 | {} 264 | {"mbox" "mailto:milt@yetanalytics.com" 265 | "openid" "https://some.site.com/foo"})) 266 | (testing 267 | "objectType" 268 | (testing "must be Agent if provided" 269 | (key-should-satisfy+ ::xs/agent 270 | {"mbox" "mailto:milt@yetanalytics.com"} 271 | "objectType" 272 | "Agent" 273 | :bad 274 | "Group")))) 275 | 276 | (deftest group-test 277 | (testing 278 | "Anonymous Groups" 279 | (testing "must have a member property" 280 | (should-satisfy+ ::xs/group 281 | {"member" [{"mbox" "mailto:milt@yetanalytics.com"}] 282 | "objectType" "Group"} 283 | :bad 284 | {"objectType" "Group"} 285 | {"member" [] 286 | "objectType" "Group"}))) 287 | (testing 288 | "Identified Group" 289 | (testing "must have one or no IFI" 290 | (should-satisfy+ ::xs/group 291 | {"mbox" "mailto:milt@yetanalytics.com" 292 | "objectType" "Group"} 293 | :bad 294 | {"objectType" "Group"} 295 | {"mbox" "mailto:milt@yetanalytics.com" 296 | "openid" "https://some.site.com/foo" 297 | "objectType" "Group"}))) 298 | (testing 299 | "objectType" 300 | (testing "must be present and be Group" 301 | (should-satisfy+ ::xs/group 302 | {"mbox" "mailto:somegroup@yetanalytics.com" 303 | "objectType" "Group"} 304 | :bad 305 | {"mbox" "mailto:so@yetanalytics.com"} 306 | {"mbox" "mailto:somegroup@yetanalytics.com" 307 | "objectType" "Agent"})))) 308 | 309 | (deftest verb-test 310 | (testing "id" 311 | (testing "is required" 312 | (should-satisfy+ ::xs/verb 313 | {"id" "http://foo.bar/baz"} 314 | :bad 315 | {})))) 316 | 317 | (deftest score-test 318 | (testing "validates score properties" 319 | (should-satisfy+ :result/score 320 | {"raw" 5 321 | "min" 1 322 | "max" 10} 323 | :bad 324 | {"raw" 5 325 | "max" 1} 326 | {"raw" 100 327 | "min" 99 328 | "max" 1})) 329 | (testing "can be empty" 330 | (should-satisfy :result/score {})) 331 | (testing "can be conformed/unformed, per https://github.com/yetanalytics/xapi-schema/issues/61" 332 | (is (= {"scaled" 0.5} 333 | (->> {"scaled" 0.5} 334 | (s/conform :result/score) 335 | (s/unform :result/score)))))) 336 | 337 | (deftest result-test 338 | (testing "can be empty" 339 | (should-satisfy ::xs/result {}))) 340 | 341 | (deftest statement-ref 342 | (testing 343 | "id" 344 | (testing "is required" 345 | (should-satisfy+ ::xs/statement-ref 346 | {"objectType" "StatementRef" 347 | "id" "f47ac10b-58cc-4372-a567-0e02b2c3d479"} 348 | :bad 349 | {"objectType" "StatementRef"}))) 350 | (testing 351 | "objectType" 352 | (testing "is required and must be StatementRef" 353 | (should-satisfy+ ::xs/statement-ref 354 | {"objectType" "StatementRef" 355 | "id" "f47ac10b-58cc-4372-a567-0e02b2c3d479"} 356 | :bad 357 | {"objectType" "foo" 358 | "id" "f47ac10b-58cc-4372-a567-0e02b2c3d479"} 359 | {"id" "f47ac10b-58cc-4372-a567-0e02b2c3d479"})))) 360 | 361 | (deftest context-activities-test 362 | (testing "is an array of 1+ activities or single activity" 363 | (should-satisfy+ ::xs/context-activities 364 | [{"id" "http://foo.bar/baz" 365 | "objectType" "Activity"}] 366 | [{"id" "http://foo.bar/baz" 367 | "objectType" "Activity"} 368 | {"id" "http://foo.bar/biz" 369 | "objectType" "Activity"}] 370 | {"id" "http://foo.bar/baz" 371 | "objectType" "Activity"} 372 | :bad 373 | [] 374 | ["foo"]))) 375 | 376 | (deftest context-activities-map-test 377 | (testing "can be empty" 378 | (should-satisfy :context/contextActivities {}))) 379 | 380 | (deftest context-test 381 | (testing "can be empty" 382 | (should-satisfy ::xs/context {})) 383 | (testing 384 | "team" 385 | (testing "must be a group" 386 | (should-satisfy+ ::xs/context 387 | {"team" {"mbox" "mailto:a@b.com" 388 | "objectType" "Group"}} 389 | :bad 390 | {"team" {"mbox" "mailto:a@b.com"}} 391 | {"team" {"mbox" "mailto:a@b.com" 392 | "objectType" "Agent"}}))) 393 | (testing "xAPI 2.0.0" 394 | (binding [xs/*xapi-version* "2.0.0"] 395 | (testing "contextAgents" 396 | (should-satisfy+ 397 | ::xs/context 398 | {"contextAgents" 399 | [{:objectType "contextAgent" 400 | :agent {"mbox" "mailto:a@b.com" 401 | "objectType" "Agent"}}]} 402 | :bad 403 | {"contextAgents" [{"mbox" "mailto:a@b.com" 404 | "objectType" "Agent"}]} 405 | {"contextAgents" 406 | [{:objectType "contextGroup" 407 | :group {"mbox" "mailto:a@b.com" 408 | "objectType" "Group"}}]})) 409 | (testing "contextGroups" 410 | (should-satisfy+ 411 | ::xs/context 412 | {"contextGroups" 413 | [{:objectType "contextGroup" 414 | :group {"mbox" "mailto:a@b.com" 415 | "objectType" "Group"}}]} 416 | :bad 417 | {"contextGroups" [{"mbox" "mailto:a@b.com" 418 | "objectType" "Group"}]} 419 | {"contextGroups" 420 | [{:objectType "contextAgent" 421 | :agent {"mbox" "mailto:a@b.com" 422 | "objectType" "Agent"}}]}))))) 423 | 424 | (deftest attachment-test 425 | (testing 426 | "usageType, display, contentType, length, sha2" 427 | (testing "are required" 428 | (should-satisfy+ ::xs/attachment 429 | {"usageType" "http://foo.bar/baz" 430 | "display" {"en-US" "foo"} 431 | "contentType" "application/json" 432 | "length" 1024 433 | "sha2" "672fa5fa658017f1b72d65036f13379c6ab05d4ab3b6664908d8acf0b6a0c634"} 434 | :bad 435 | {} 436 | {"usageType" "http://foo.bar/baz"})))) 437 | 438 | (deftest url-attachment-test 439 | (testing 440 | "usageType, display, contentType, length, sha2, fileUrl" 441 | (testing "are required" 442 | (should-satisfy+ ::xs/attachment 443 | {"usageType" "http://foo.bar/baz" 444 | "display" {"en-US" "foo"} 445 | "contentType" "application/json" 446 | "length" 1024 447 | "sha2" "672fa5fa658017f1b72d65036f13379c6ab05d4ab3b6664908d8acf0b6a0c634" 448 | "fileUrl" "http://foo.bar/baz"} 449 | :bad 450 | {} 451 | {"usageType" "http://foo.bar/baz"})))) 452 | 453 | (deftest attachments-test 454 | (testing "is an array of at least one attachment" 455 | (should-satisfy+ ::xs/attachments 456 | [{"usageType" "http://foo.bar/baz" 457 | "display" {"en-US" "foo"} 458 | "contentType" "application/json" 459 | "length" 1024 460 | "sha2" "672fa5fa658017f1b72d65036f13379c6ab05d4ab3b6664908d8acf0b6a0c634"}] 461 | :bad 462 | [] 463 | {}))) 464 | 465 | (deftest sub-statement-test 466 | (let [minimal-sub-statement d/sub-statement] 467 | (testing "must not have the id, stored, version, or authority properties" 468 | (should-satisfy+ ::xs/sub-statement 469 | minimal-sub-statement 470 | :bad 471 | (assoc minimal-sub-statement "id" d/uuid-str) 472 | (assoc minimal-sub-statement "stored" d/timestamp) 473 | (assoc minimal-sub-statement "version" d/version) 474 | (assoc minimal-sub-statement "authority" d/agente))) 475 | (testing 476 | "actor" 477 | (testing "is required" 478 | (should-not-satisfy ::xs/sub-statement (dissoc minimal-sub-statement "actor"))) 479 | (testing "is an agent or group" 480 | (key-should-satisfy+ ::xs/sub-statement 481 | minimal-sub-statement 482 | "actor" 483 | d/agente 484 | d/group 485 | d/anon-group 486 | :bad 487 | "foo" 488 | {}))) 489 | (testing 490 | "verb" 491 | (testing "is required" 492 | (should-not-satisfy ::xs/sub-statement (dissoc minimal-sub-statement "verb"))) 493 | (testing "is a Verb" 494 | (key-should-satisfy+ ::xs/sub-statement 495 | minimal-sub-statement 496 | "verb" 497 | d/verb 498 | :bad 499 | [] 500 | {} 501 | d/activity 502 | d/agente))) 503 | (testing 504 | "object" 505 | (testing "is required" 506 | (should-not-satisfy ::xs/sub-statement 507 | (dissoc minimal-sub-statement "object"))) 508 | (testing "is an Activity, Agent, Group, or StatementRef" 509 | (key-should-satisfy+ ::xs/sub-statement 510 | minimal-sub-statement 511 | "object" 512 | ;; Activity is the default 513 | (dissoc d/activity "objectType") 514 | ;; explicit Activity ObjectType 515 | d/activity 516 | d/agente 517 | d/group 518 | d/anon-group 519 | d/statement-ref 520 | :bad 521 | [] 522 | {}))) 523 | (testing 524 | "objectType" 525 | (testing "is required" 526 | (should-not-satisfy ::xs/sub-statement (dissoc minimal-sub-statement "objectType")))))) 527 | 528 | (deftest oauth-consumer-test 529 | (testing "must be identified by account" 530 | (should-satisfy+ 531 | ::xs/oauth-consumer 532 | {"account" {"name" "oauth_consumer_x75db" 533 | "homePage" "http://example.com/xAPI/OAuth/Token"}} 534 | :bad 535 | {"mbox" "mailto:milt@yetanalytics.com"}))) 536 | 537 | (deftest three-legged-oauth-group-test 538 | (testing "must be a group with two agents" 539 | (should-satisfy+ 540 | ::xs/tlo-group 541 | d/authority-group 542 | :bad 543 | (update-in d/authority-group ["member"] (comp vector first)))) 544 | (testing "must have an OAuthConsumer for the first member" 545 | (should-satisfy+ 546 | ::xs/tlo-group 547 | d/authority-group 548 | :bad 549 | (assoc d/authority-group "member" [d/agente d/agente])))) 550 | 551 | (deftest authority-test 552 | (testing "must be an agent" 553 | (should-satisfy 554 | :statement/authority 555 | d/agente)) 556 | (testing 557 | "except in the case of three-legged-oauth, when" 558 | (testing "can be a group with two agents" 559 | (should-satisfy 560 | :statement/authority 561 | d/authority-group)))) 562 | 563 | (deftest statement-object 564 | (testing "is an Agent, Group, SubStatement, StatementRef or Activity" 565 | (should-satisfy+ :statement/object 566 | d/agente 567 | d/group 568 | d/anon-group 569 | d/sub-statement 570 | d/statement-ref 571 | d/activity 572 | (dissoc d/activity "objectType") 573 | :bad 574 | [] 575 | {} 576 | d/verb))) 577 | 578 | (deftest statement-test 579 | (testing 580 | "actor" 581 | (testing "is required" 582 | (should-not-satisfy ::xs/statement (dissoc long-statement "actor"))) 583 | (testing "is an agent or group" 584 | (key-should-satisfy+ ::xs/statement 585 | long-statement 586 | "actor" 587 | d/agente 588 | d/group 589 | d/anon-group 590 | :bad 591 | "foo" 592 | {}))) 593 | (testing 594 | "verb" 595 | (testing "is required" 596 | (should-not-satisfy ::xs/statement (dissoc long-statement "verb"))) 597 | (testing "is a Verb" 598 | (key-should-satisfy+ ::xs/statement 599 | long-statement 600 | "verb" 601 | d/verb 602 | :bad 603 | [] 604 | {} 605 | d/activity 606 | d/agente))) 607 | (testing 608 | "object" 609 | (testing "is required" 610 | (should-not-satisfy ::xs/statement 611 | (dissoc long-statement "object"))) 612 | (testing "is an Activity, Agent, Group, StatementRef, or sub-statement" 613 | (key-should-satisfy+ ::xs/statement 614 | ;; use one without context so we can swap non-activity objects 615 | (dissoc long-statement "context") 616 | "object" 617 | ;; Activity is the default 618 | (dissoc d/activity "objectType") 619 | ;; explicit Activity ObjectType 620 | d/activity 621 | d/agente 622 | d/group 623 | d/anon-group 624 | d/sub-statement 625 | :bad 626 | [] 627 | {}))) 628 | (testing 629 | "context" 630 | (testing "when the statement object is an activity" 631 | (let [statement (assoc-in long-statement ["context" "revision"] "whatevs")] 632 | (testing "can have the platform and revision properties" 633 | (should-satisfy ::xs/statement statement)))) 634 | (testing "when the statement object is not an activity" 635 | (let [statement d/void-statement] 636 | (testing "cannot have the platform and revision properties" 637 | (should-satisfy+ ::xs/statement 638 | statement 639 | :bad 640 | (assoc statement "context" {"platform" "Apple Newton"}) 641 | (assoc statement "context" {"revision" "whatevs"})))))) 642 | 643 | (testing "is satisfied by all ADL example statements" 644 | (should-satisfy+ ::xs/statement 645 | simple-statement 646 | long-statement 647 | d/completion-statement 648 | d/void-statement)) 649 | (testing "checks for the proper object objectType on voiding statements" 650 | (should-satisfy+ ::xs/statement 651 | d/void-statement 652 | :bad 653 | {"actor" {"objectType" "Agent" 654 | "name" "Example Admin" 655 | "mbox" "mailto:admin@example.adlnet.gov"} 656 | "verb" {"id" "http://adlnet.gov/expapi/verbs/voided" 657 | "display" {"en-US" "voided"}} 658 | "object" {"id" "http://example.com/activities/1"}}))) 659 | 660 | (deftest statements-test 661 | (testing "generic statememt batch" 662 | (should-satisfy+ ::xs/statements 663 | [simple-statement] 664 | [simple-statement long-statement] 665 | [])) 666 | (testing "LRS retrieval statement batch" 667 | (should-satisfy+ ::xs/lrs-statements 668 | [d/statement] ; This statement has ID and other required fields 669 | []))) 670 | -------------------------------------------------------------------------------- /src/xapi_schema/spec.cljc: -------------------------------------------------------------------------------- 1 | (ns xapi-schema.spec 2 | (:require 3 | [xapi-schema.spec.regex :refer [LanguageTagRegEx 4 | OpenIdRegEx 5 | AbsoluteIRIRegEx 6 | RelativeIRLRegEx 7 | MailToIRIRegEx 8 | UuidRegEx 9 | TimestampRegEx 10 | TimestampRegEx200 11 | xAPIVersionRegEx 12 | xAPIVersionRegEx200 13 | DurationRegEx 14 | DurationRegEx200 15 | Sha1RegEx 16 | Sha2RegEx]] 17 | [clojure.spec.alpha :as s #?@(:cljs [:include-macros true])] 18 | [clojure.spec.gen.alpha :as sgen :include-macros true] 19 | [clojure.string :as cstr] 20 | #?@(:cljs [[goog.string :as gstring] 21 | [goog.string.format]])) 22 | #?(:cljs (:require-macros [xapi-schema.spec :refer [conform-ns]]))) 23 | 24 | (def ^:dynamic *xapi-0-95-compat?* 25 | "When true, coerce 0.95 context activities to conform." 26 | true) 27 | 28 | (def ^:dynamic *xapi-version* 29 | "xAPI Statement Version to Conform" 30 | "1.0.3") 31 | 32 | ;; Utils 33 | 34 | (def double-conformer 35 | (s/conformer (fn [n] 36 | (try (double n) 37 | (catch #?(:clj Exception :cljs js/Error) _ 38 | ::s/invalid))) 39 | ;; unform is a no-op, as json doesn't care 40 | identity)) 41 | 42 | (defn conform-ns-map [map-ns string-map] 43 | (if (map? string-map) 44 | (try (reduce-kv (fn [m k v] 45 | (assoc m (cond 46 | (string? k) 47 | (keyword map-ns k) 48 | (simple-keyword? k) 49 | (keyword map-ns (name k)) 50 | :else k) v)) 51 | {} 52 | string-map) 53 | (catch #?(:clj Exception :cljs js/Error) _ 54 | ::s/invalid)) 55 | ::s/invalid)) 56 | 57 | (defn unform-ns-map [keyword-map] 58 | (try (reduce-kv (fn [m k v] 59 | (assoc m (if (qualified-keyword? k) 60 | (name k) 61 | k) v)) 62 | {} 63 | keyword-map) 64 | (catch #?(:clj Exception :cljs js/Error) _ 65 | ::s/invalid))) 66 | 67 | (defn map-ns-conformer 68 | [map-ns] 69 | (s/conformer 70 | (partial conform-ns-map map-ns) 71 | unform-ns-map)) 72 | 73 | #?(:clj (defmacro conform-ns 74 | [map-ns & spec-body] 75 | `(s/with-gen (s/and 76 | (s/conformer 77 | (partial conform-ns-map ~map-ns) 78 | unform-ns-map) 79 | ~@spec-body) 80 | #(sgen/fmap 81 | unform-ns-map 82 | (s/gen ~@spec-body))))) 83 | 84 | (defn restrict-keys 85 | "Return a predicate that asserts that only the given keys are present." 86 | [& ks] 87 | (fn [m] (every? (set ks) (keys m)))) 88 | 89 | (def revision-or-platform? 90 | (comp some? 91 | (some-fn :context/revision 92 | :context/platform))) 93 | 94 | ;; primitives - useful to hook into for generation 95 | (s/def ::string-not-empty 96 | (s/with-gen 97 | (s/and string? 98 | (complement empty?)) 99 | #(sgen/not-empty (sgen/string-alphanumeric)))) 100 | 101 | ;; Leaves 102 | 103 | (s/def ::language-tag 104 | (s/with-gen 105 | (s/and ::string-not-empty 106 | (partial re-matches LanguageTagRegEx)) 107 | #(sgen/elements ["en" "en-US" "en-GB" "fr"]))) 108 | 109 | (s/def ::language-map-text 110 | (s/with-gen string? ;; allow empty strings 111 | #(s/gen ::string-not-empty))) 112 | 113 | (s/def ::language-map 114 | (s/map-of ::language-tag 115 | ::language-map-text 116 | :gen-max 3)) 117 | 118 | 119 | (defn into-str [cs] 120 | (cstr/lower-case (apply str cs))) 121 | 122 | (s/def ::iri 123 | (s/with-gen 124 | (s/and string? 125 | (partial re-matches AbsoluteIRIRegEx)) 126 | #(sgen/fmap 127 | (fn [[protocol host domain tld path]] 128 | (str protocol "://" host "." domain "." tld "/" path)) ;; TODO: dynamic protocol 129 | (sgen/tuple 130 | (sgen/fmap into-str 131 | (sgen/vector (sgen/char-alpha) 3 8)) 132 | (sgen/fmap into-str 133 | (sgen/vector (sgen/char-alpha) 3 10)) 134 | (sgen/fmap into-str 135 | (sgen/vector (sgen/char-alpha) 3 10)) 136 | (sgen/fmap into-str 137 | (sgen/vector (sgen/char-alpha) 3 4)) 138 | (sgen/fmap into-str 139 | (sgen/vector (sgen/char-alpha) 3 16)))))) 140 | 141 | (s/def ::mailto-iri 142 | (s/with-gen 143 | (s/and string? 144 | (partial re-matches MailToIRIRegEx)) 145 | #(sgen/fmap 146 | (fn [[mbox domain tld]] 147 | (str "mailto:" mbox "@" domain "." tld)) 148 | (sgen/tuple (sgen/fmap into-str 149 | (sgen/vector (sgen/char-alpha) 3 16)) 150 | (sgen/fmap into-str 151 | (sgen/vector (sgen/char-alpha) 3 12)) 152 | (sgen/fmap into-str 153 | (sgen/vector (sgen/char-alpha) 3 4)))))) 154 | 155 | (s/def ::irl 156 | (s/with-gen 157 | (s/and string? 158 | (partial re-matches AbsoluteIRIRegEx)) 159 | #(sgen/fmap 160 | (fn [[protocol host domain tld path]] 161 | (str protocol "://" host "." domain "." tld "/" path)) ;; TODO: dynamic protocol 162 | (sgen/tuple 163 | (sgen/fmap into-str 164 | (sgen/vector (sgen/char-alpha) 3 8)) 165 | (sgen/fmap into-str 166 | (sgen/vector (sgen/char-alpha) 3 10)) 167 | (sgen/fmap into-str 168 | (sgen/vector (sgen/char-alpha) 3 10)) 169 | (sgen/fmap into-str 170 | (sgen/vector (sgen/char-alpha) 3 4)) 171 | (sgen/fmap into-str 172 | (sgen/vector (sgen/char-alpha) 3 16)))))) 173 | 174 | (s/def ::relative-irl 175 | (s/with-gen 176 | (s/and string? 177 | (partial re-matches RelativeIRLRegEx)) 178 | #(sgen/fmap 179 | (fn [path] 180 | (str "/" path)) ;; TODO: dynamic protocol 181 | (sgen/fmap into-str 182 | (sgen/vector (sgen/char-alpha) 3 16))))) 183 | 184 | (s/def ::any-json 185 | (s/nilable 186 | (s/or :scalar 187 | (s/or :string 188 | string? 189 | :number 190 | (s/or :double 191 | (s/double-in :infinite? false :NaN? false) 192 | :int 193 | int?) 194 | :boolean 195 | boolean?) 196 | :coll 197 | (s/or :map 198 | (s/map-of 199 | string? 200 | ::any-json 201 | :gen-max 4) 202 | :vector 203 | (s/coll-of 204 | ::any-json 205 | :kind vector? 206 | :into [] 207 | :gen-max 4))))) 208 | 209 | (s/def ::extensions 210 | (s/map-of ::iri 211 | ::any-json)) 212 | 213 | (s/def ::openid 214 | (s/with-gen 215 | (s/and string? 216 | (partial re-matches OpenIdRegEx)) 217 | #(sgen/fmap 218 | (fn [[protocol host domain tld path]] 219 | (str protocol "://" host "." domain "." tld "/" path)) 220 | (sgen/tuple (sgen/elements ["http" "https"]) 221 | (sgen/fmap into-str 222 | (sgen/vector (sgen/char-alpha) 3 10)) 223 | (sgen/fmap into-str 224 | (sgen/vector (sgen/char-alpha) 3 10)) 225 | (sgen/fmap into-str 226 | (sgen/vector (sgen/char-alpha) 3 4)) 227 | (sgen/fmap into-str 228 | (sgen/vector (sgen/char-alpha) 3 16)))))) 229 | 230 | 231 | (s/def ::uuid 232 | (s/with-gen 233 | (s/and string? 234 | (partial re-matches UuidRegEx)) 235 | #(sgen/fmap 236 | str 237 | (sgen/uuid)))) 238 | 239 | ;; Note: currently ignores leap seconds 240 | (defn- valid-timestamp? 241 | [timestamp] 242 | (letfn [(parse-int [s] #?(:clj (Integer/parseInt s) :cljs (js/parseInt s)))] 243 | (let [[ts year month day _hour _min _sec _sec-frac _offset] 244 | (re-matches 245 | (case *xapi-version* 246 | "1.0.3" TimestampRegEx 247 | "2.0.0" TimestampRegEx200) 248 | timestamp) 249 | month-int (when month (parse-int month)) 250 | year-int (when year (parse-int year)) 251 | day-int (when day (parse-int day))] 252 | (cond 253 | (nil? ts) ; fails regex 254 | false 255 | (= 2 month-int) 256 | (if (or (and (= 0 (mod year-int 4)) (not= 0 (mod year-int 100))) 257 | (= 0 (mod year-int 400))) 258 | (not (>= day-int 30)) ; leap year 259 | (not (>= day-int 29))) 260 | (#{4 6 9 11} month-int) 261 | (not (>= day-int 31)) 262 | :else 263 | true)))) ; day-int is always maxed out at 31 264 | 265 | (s/def ::timestamp 266 | (s/with-gen 267 | (s/and string? valid-timestamp?) 268 | #(sgen/fmap (fn [[yyyy mm dd h m s ms]] 269 | (#?(:clj format :cljs gstring/format) 270 | "%d-%02d-%02dT%02d:%02d:%02d.%dZ" yyyy mm dd h m s ms)) 271 | (sgen/tuple (sgen/elements (range 1970 2024)) 272 | (sgen/elements (range 1 13)) 273 | (sgen/elements (range 1 29)) 274 | (sgen/elements (range 0 25)) 275 | (sgen/elements (range 0 60)) 276 | (sgen/elements (range 0 60)) 277 | (sgen/elements (range 0 1000)))))) 278 | 279 | 280 | (s/def ::duration 281 | (s/with-gen 282 | (s/and string? 283 | #(re-matches 284 | (case *xapi-version* 285 | "1.0.3" DurationRegEx 286 | "2.0.0" DurationRegEx200) 287 | %)) 288 | #(sgen/fmap (fn [[h m s]] 289 | (#?(:clj format 290 | :cljs gstring/format) "PT%dH%sM%dS" h m s)) 291 | (sgen/tuple (sgen/elements (range 1 24)) 292 | (sgen/elements (range 1 60)) 293 | (sgen/elements (range 1 60)))))) 294 | 295 | (s/def ::version 296 | (s/with-gen 297 | (s/and string? 298 | #(re-matches 299 | (case *xapi-version* 300 | "1.0.3" xAPIVersionRegEx 301 | "2.0.0" xAPIVersionRegEx200) 302 | %)) 303 | #(sgen/return *xapi-version*))) 304 | 305 | (s/def ::sha2 306 | (s/with-gen 307 | (s/and string? 308 | (partial re-matches Sha2RegEx)) 309 | #(sgen/fmap 310 | (fn [is] 311 | (apply str (map char is))) 312 | (sgen/vector (sgen/elements (concat 313 | (range 65 71) 314 | (range 48 58))) 315 | 64)))) 316 | 317 | (s/def ::sha1sum 318 | (s/with-gen 319 | (s/and string? 320 | (partial re-matches Sha1RegEx)) 321 | #(sgen/fmap 322 | (fn [is] 323 | (apply str 324 | (map char 325 | is))) 326 | (sgen/vector (sgen/elements (concat 327 | (range 65 71) 328 | (range 48 58))) 329 | 40)))) 330 | 331 | ;; Activity Definition 332 | 333 | (s/def :interaction-component/id 334 | ::string-not-empty) 335 | 336 | (s/def :interaction-component/description 337 | ::language-map) 338 | 339 | (s/def ::interaction-component 340 | (conform-ns "interaction-component" 341 | (s/and 342 | (s/keys :req [:interaction-component/id] 343 | :opt [:interaction-component/description]) 344 | (restrict-keys :interaction-component/id 345 | :interaction-component/description)))) 346 | 347 | (s/def ::interaction-components 348 | (s/with-gen 349 | (s/and (s/coll-of ::interaction-component :kind vector? :into []) 350 | (fn [icomps] 351 | (when (seq icomps) 352 | (apply distinct? (map (some-fn 353 | :interaction-component/id 354 | #(get % "id")) icomps))))) 355 | #(sgen/not-empty (sgen/vector-distinct (s/gen ::interaction-component))))) 356 | 357 | 358 | (s/def :definition/name 359 | ::language-map) 360 | 361 | (s/def :definition/description 362 | ::language-map) 363 | 364 | (s/def :definition/correctResponsesPattern 365 | (s/coll-of string? :kind vector?)) 366 | 367 | (s/def :definition/type 368 | ::iri) 369 | 370 | (s/def :definition/moreInfo 371 | ::irl) 372 | 373 | (s/def :definition/choices 374 | ::interaction-components) 375 | 376 | (s/def :definition/scale 377 | ::interaction-components) 378 | 379 | (s/def :definition/source 380 | ::interaction-components) 381 | 382 | (s/def :definition/target 383 | ::interaction-components) 384 | 385 | (s/def :definition/steps 386 | ::interaction-components) 387 | 388 | (s/def :definition/extensions 389 | ::extensions) 390 | 391 | (s/def :definition/interactionType 392 | #{"true-false" 393 | "choice" 394 | "fill-in" 395 | "long-fill-in" 396 | "matching" 397 | "performance" 398 | "sequencing" 399 | "likert" 400 | "numeric" 401 | "other"}) 402 | 403 | (defmulti interaction-type :definition/interactionType) 404 | 405 | (defmethod interaction-type "choice" [_] 406 | (s/and 407 | (s/keys 408 | :req [:definition/interactionType] 409 | :opt [:definition/name 410 | :definition/description 411 | :definition/correctResponsesPattern 412 | :definition/type 413 | :definition/moreInfo 414 | :definition/choices 415 | :definition/extensions]) 416 | (restrict-keys :definition/name 417 | :definition/description 418 | :definition/correctResponsesPattern 419 | :definition/interactionType 420 | :definition/type 421 | :definition/moreInfo 422 | :definition/choices 423 | :definition/extensions))) 424 | 425 | (defmethod interaction-type "sequencing" [_] 426 | (s/and 427 | (s/keys 428 | :req [:definition/interactionType] 429 | :opt [:definition/name 430 | :definition/description 431 | :definition/correctResponsesPattern 432 | :definition/type 433 | :definition/moreInfo 434 | :definition/choices 435 | :definition/extensions]) 436 | (restrict-keys :definition/name 437 | :definition/description 438 | :definition/correctResponsesPattern 439 | :definition/interactionType 440 | :definition/type 441 | :definition/moreInfo 442 | :definition/choices 443 | :definition/extensions))) 444 | 445 | (defmethod interaction-type "likert" [_] 446 | (s/and 447 | (s/keys 448 | :req [:definition/interactionType] 449 | :opt [:definition/name 450 | :definition/description 451 | :definition/correctResponsesPattern 452 | :definition/type 453 | :definition/moreInfo 454 | :definition/scale 455 | :definition/extensions]) 456 | (restrict-keys :definition/name 457 | :definition/description 458 | :definition/correctResponsesPattern 459 | :definition/interactionType 460 | :definition/type 461 | :definition/moreInfo 462 | :definition/scale 463 | :definition/extensions))) 464 | 465 | (defmethod interaction-type "matching" [_] 466 | (s/and 467 | (s/keys 468 | :req [:definition/interactionType] 469 | :opt [:definition/name 470 | :definition/description 471 | :definition/correctResponsesPattern 472 | :definition/type 473 | :definition/moreInfo 474 | :definition/source 475 | :definition/target 476 | :definition/extensions]) 477 | (restrict-keys :definition/name 478 | :definition/description 479 | :definition/correctResponsesPattern 480 | :definition/interactionType 481 | :definition/type 482 | :definition/moreInfo 483 | :definition/source 484 | :definition/target 485 | :definition/extensions))) 486 | 487 | (defmethod interaction-type "performance" [_] 488 | (s/and 489 | (s/keys 490 | :req [:definition/interactionType] 491 | :opt [:definition/name 492 | :definition/description 493 | :definition/correctResponsesPattern 494 | :definition/type 495 | :definition/moreInfo 496 | :definition/steps 497 | :definition/extensions]) 498 | (restrict-keys :definition/name 499 | :definition/description 500 | :definition/correctResponsesPattern 501 | :definition/interactionType 502 | :definition/type 503 | :definition/moreInfo 504 | :definition/steps 505 | :definition/extensions))) 506 | 507 | (defmethod interaction-type nil [_] 508 | (s/and 509 | (s/keys 510 | :opt [:definition/name 511 | :definition/description 512 | :definition/type 513 | :definition/moreInfo 514 | :definition/extensions]) 515 | (restrict-keys :definition/name 516 | :definition/description 517 | :definition/type 518 | :definition/moreInfo 519 | :definition/extensions))) 520 | 521 | (defmethod interaction-type :default [_] 522 | (s/and 523 | (s/keys 524 | :req [:definition/interactionType] 525 | :opt [:definition/name 526 | :definition/description 527 | :definition/correctResponsesPattern 528 | :definition/type 529 | :definition/moreInfo 530 | :definition/extensions]) 531 | (restrict-keys :definition/name 532 | :definition/description 533 | :definition/correctResponsesPattern 534 | :definition/interactionType 535 | :definition/type 536 | :definition/moreInfo 537 | :definition/extensions))) 538 | 539 | 540 | (s/def :activity/definition 541 | (conform-ns "definition" 542 | (s/multi-spec interaction-type (fn [gen-val _] 543 | gen-val 544 | #_:definition/interactionType)))) 545 | 546 | (s/def :activity/objectType 547 | #{"Activity"}) 548 | 549 | (s/def :activity/id 550 | ::iri) 551 | 552 | (s/def ::activity 553 | (conform-ns "activity" 554 | (s/and 555 | (s/keys :req [:activity/id] 556 | :opt [:activity/objectType 557 | :activity/definition]) 558 | (restrict-keys :activity/id 559 | :activity/objectType 560 | :activity/definition)))) 561 | 562 | ;; Account 563 | 564 | (s/def :account/name 565 | ::string-not-empty) 566 | 567 | (s/def :account/homePage 568 | ::irl) 569 | 570 | (s/def ::account 571 | (conform-ns "account" 572 | (s/and 573 | (s/keys :req [:account/name 574 | :account/homePage]) 575 | (restrict-keys :account/name 576 | :account/homePage)))) 577 | 578 | ;; Agent 579 | 580 | (s/def :agent/objectType 581 | #{"Agent"}) 582 | 583 | (s/def :agent/name 584 | string?) 585 | 586 | (s/def :agent/mbox 587 | ::mailto-iri) 588 | 589 | (s/def :agent/mbox_sha1sum 590 | ::sha1sum) 591 | 592 | (s/def :agent/openid 593 | ::openid) 594 | 595 | (s/def :agent/account 596 | ::account) 597 | 598 | (defn max-one-ifi 599 | "Assert that agents/groups only have one IFI" 600 | [a] 601 | (>= 1 (count (select-keys a [:agent/mbox 602 | :agent/mbox_sha1sum 603 | :agent/openid 604 | :agent/account 605 | :group/mbox 606 | :group/mbox_sha1sum 607 | :group/openid 608 | :group/account])))) 609 | 610 | (s/def ::agent 611 | (s/with-gen (s/and 612 | (s/conformer 613 | (partial conform-ns-map "agent") 614 | unform-ns-map) 615 | (s/keys :req [(or :agent/mbox 616 | :agent/mbox_sha1sum 617 | :agent/openid 618 | :agent/account)] 619 | :opt [:agent/name 620 | :agent/objectType]) 621 | (restrict-keys :agent/mbox 622 | :agent/mbox_sha1sum 623 | :agent/openid 624 | :agent/account 625 | :agent/name 626 | :agent/objectType) 627 | max-one-ifi) 628 | #(sgen/fmap 629 | unform-ns-map 630 | (s/gen (s/or :ifi-mbox 631 | (s/keys :req [:agent/mbox] 632 | :opt [:agent/name 633 | :agent/objectType]) 634 | :ifi-mbox_sha1sum 635 | (s/keys :req [:agent/mbox_sha1sum] 636 | :opt [:agent/name 637 | :agent/objectType]) 638 | :ifi-openid 639 | (s/keys :req [:agent/openid] 640 | :opt [:agent/name 641 | :agent/objectType]) 642 | :ifi-account 643 | (s/keys :req [:agent/account] 644 | :opt [:agent/name 645 | :agent/objectType])))))) 646 | 647 | ;; Group 648 | 649 | (s/def :group/objectType 650 | #{"Group"}) 651 | 652 | (s/def :group/name 653 | string?) 654 | 655 | (s/def :group/mbox 656 | ::mailto-iri) 657 | 658 | (s/def :group/mbox_sha1sum 659 | ::sha1sum) 660 | 661 | (s/def :group/openid 662 | ::openid) 663 | 664 | (s/def :group/account 665 | ::account) 666 | 667 | (s/def :group/member 668 | (s/coll-of ::agent :kind vector? :into [] :gen-max 3)) 669 | 670 | (s/def ::identified-group 671 | (s/keys :req [:group/objectType 672 | (or :group/mbox 673 | :group/mbox_sha1sum 674 | :group/openid 675 | :group/account)] 676 | :opt [:group/name 677 | :group/member])) 678 | 679 | (s/def ::anonymous-group 680 | (s/and 681 | (s/keys :req [:group/objectType 682 | :group/member] 683 | :opt [:group/name]) 684 | #(-> % :group/member seq))) 685 | 686 | (def identified-group? 687 | (comp 688 | some? 689 | (some-fn :group/mbox 690 | :group/mbox_sha1sum 691 | :group/openid 692 | :group/account))) 693 | 694 | (defmulti group-type #(if (identified-group? %) 695 | :group/identified 696 | :group/anonymous)) 697 | 698 | (defmethod group-type :group/identified [_] 699 | ::identified-group) 700 | 701 | (defmethod group-type :group/anonymous [_] 702 | ::anonymous-group) 703 | 704 | 705 | (s/def ::group 706 | (s/with-gen (s/and 707 | (s/conformer 708 | (partial conform-ns-map "group") 709 | unform-ns-map) 710 | (s/multi-spec group-type (fn [gen-val _] 711 | gen-val)) 712 | (restrict-keys :group/mbox 713 | :group/mbox_sha1sum 714 | :group/openid 715 | :group/account 716 | :group/name 717 | :group/objectType 718 | :group/member) 719 | max-one-ifi) 720 | #(sgen/fmap 721 | unform-ns-map 722 | (s/gen (s/or :ifi-mbox 723 | (s/keys :req [:group/mbox] 724 | :opt [:group/member 725 | :group/name 726 | :group/objectType]) 727 | :ifi-mbox_sha1sum 728 | (s/keys :req [:group/mbox_sha1sum] 729 | :opt [:group/member 730 | :group/name 731 | :group/objectType]) 732 | :ifi-openid 733 | (s/keys :req [:group/openid] 734 | :opt [:group/member 735 | :group/name 736 | :group/objectType]) 737 | :ifi-account 738 | (s/keys :req [:group/account] 739 | :opt [:group/member 740 | :group/name 741 | :group/objectType]) 742 | :anon 743 | (s/keys :req [:group/member] 744 | :opt [:group/name 745 | :group/objectType])))))) 746 | 747 | ;; Actor 748 | 749 | (defmulti actor-type (fn [a] 750 | (case (get a "objectType") 751 | "Agent" :actor/agent 752 | "Group" :actor/group 753 | :actor/agent))) 754 | 755 | (defmethod actor-type :actor/agent [_] 756 | ::agent) 757 | 758 | (defmethod actor-type :actor/group [_] 759 | ::group) 760 | 761 | 762 | (s/def ::actor 763 | (s/multi-spec actor-type (fn [gen-val _] 764 | gen-val))) 765 | 766 | ;; Verb 767 | 768 | (s/def :verb/id ::iri) 769 | 770 | (s/def :verb/display ::language-map) 771 | 772 | (s/def 773 | ::verb 774 | (conform-ns "verb" 775 | (s/and 776 | (s/keys :req [:verb/id] 777 | :opt [:verb/display]) 778 | (restrict-keys :verb/id 779 | :verb/display)))) 780 | 781 | ;; Result 782 | 783 | (s/def :score/scaled 784 | (s/with-gen 785 | (s/and 786 | double-conformer 787 | (s/double-in :min -1.0 :max 1.0 :infinite? false :NaN? false)) 788 | #(sgen/double* {:min -1.0 :max 1.0 789 | :infinite? false 790 | :NaN? false}))) 791 | 792 | (def safe-double-spec 793 | (s/with-gen 794 | (s/and 795 | double-conformer 796 | (s/double-in :infinite? false :NaN? false)) 797 | #(sgen/double* {:infinite? false 798 | :NaN? false}))) 799 | 800 | (s/def :score/raw 801 | safe-double-spec) 802 | 803 | (s/def :score/min 804 | safe-double-spec) 805 | 806 | (s/def :score/max 807 | safe-double-spec) 808 | 809 | (defn valid-min-max-raw? 810 | [{raw :score/raw 811 | min :score/min 812 | max :score/max}] 813 | (if (or min raw max) 814 | (apply <= (filter identity [min raw max])) 815 | true)) 816 | 817 | (defn coerce-min-max-raw 818 | [{raw :score/raw 819 | min :score/min 820 | max :score/max 821 | :as scores}] 822 | (cond 823 | (and min raw max (not (<= min raw max))) 824 | (let [ord (vec (sort [min raw max]))] 825 | (-> scores 826 | (assoc :score/min (get ord 0)) 827 | (assoc :score/raw (get ord 1)) 828 | (assoc :score/max (get ord 2)))) 829 | 830 | (and min raw (< raw min)) 831 | (-> scores 832 | (assoc :score/min raw) 833 | (assoc :score/raw min)) 834 | 835 | (and min max (< max min)) 836 | (-> scores 837 | (assoc :score/min max) 838 | (assoc :score/max min)) 839 | 840 | (and raw max (< max raw)) 841 | (-> scores 842 | (assoc :score/raw max) 843 | (assoc :score/max raw)) 844 | 845 | :else 846 | scores)) 847 | 848 | (s/def :result/score 849 | (s/with-gen (s/and 850 | (s/conformer 851 | (partial conform-ns-map "score") 852 | unform-ns-map) 853 | (s/keys :opt [:score/scaled 854 | :score/raw 855 | :score/min 856 | :score/max]) 857 | (restrict-keys :score/scaled 858 | :score/raw 859 | :score/min 860 | :score/max) 861 | valid-min-max-raw?) 862 | #(sgen/fmap 863 | unform-ns-map 864 | (sgen/fmap 865 | coerce-min-max-raw 866 | (sgen/not-empty 867 | (s/gen (s/keys :opt [:score/scaled 868 | :score/raw 869 | :score/min 870 | :score/max]))))))) 871 | 872 | (s/def :result/success 873 | boolean?) 874 | 875 | (s/def :result/completion 876 | boolean?) 877 | 878 | (s/def :result/response 879 | string?) 880 | 881 | (s/def :result/duration 882 | ::duration) 883 | 884 | (s/def :result/extensions 885 | ::extensions) 886 | 887 | (s/def ::result 888 | (conform-ns "result" 889 | (s/and 890 | (s/keys :opt [:result/score 891 | :result/success 892 | :result/completion 893 | :result/response 894 | :result/duration 895 | :result/extensions]) 896 | (restrict-keys :result/score 897 | :result/success 898 | :result/completion 899 | :result/response 900 | :result/duration 901 | :result/extensions)))) 902 | 903 | ;; Statement Ref 904 | 905 | (s/def :statement-ref/id ::uuid) 906 | 907 | (s/def :statement-ref/objectType 908 | #{"StatementRef"}) 909 | 910 | (s/def ::statement-ref 911 | (conform-ns "statement-ref" 912 | (s/and 913 | (s/keys :req [:statement-ref/id 914 | :statement-ref/objectType]) 915 | (restrict-keys :statement-ref/id 916 | :statement-ref/objectType)))) 917 | 918 | ;; Context 919 | 920 | (s/def ::context-activities-array 921 | (s/coll-of ::activity :kind vector? :into [] :min-count 1)) 922 | 923 | (s/def ::context-activities 924 | ;; For compatibility, we let these be maps, but conform them 925 | (s/with-gen (s/and (s/conformer (fn [ca-val] 926 | (if (map? ca-val) 927 | (if *xapi-0-95-compat?* 928 | (with-meta [ca-val] 929 | {:xapi-0-95-compat-conformed? true}) 930 | ::s/invalid) 931 | ca-val)) 932 | (fn [ca-val] 933 | (if (and 934 | *xapi-0-95-compat?* 935 | (= 1 (count ca-val)) 936 | (some-> ca-val 937 | meta 938 | :xapi-0-95-compat-conformed?)) 939 | (first ca-val) 940 | ca-val))) 941 | ::context-activities-array) 942 | #(s/gen ::context-activities-array))) 943 | 944 | (s/def :contextActivities/parent 945 | ::context-activities) 946 | 947 | (s/def :contextActivities/grouping 948 | ::context-activities) 949 | 950 | (s/def :contextActivities/category 951 | ::context-activities) 952 | 953 | (s/def :contextActivities/other 954 | ::context-activities) 955 | 956 | (s/def :context/contextActivities 957 | (conform-ns "contextActivities" 958 | (s/and 959 | (s/keys :opt [:contextActivities/parent 960 | :contextActivities/grouping 961 | :contextActivities/category 962 | :contextActivities/other]) 963 | (restrict-keys :contextActivities/parent 964 | :contextActivities/grouping 965 | :contextActivities/category 966 | :contextActivities/other)))) 967 | 968 | (s/def :context/registration 969 | ::uuid) 970 | 971 | (s/def :context/instructor 972 | ::actor) 973 | 974 | (s/def :context/team 975 | ::group) 976 | 977 | (s/def :context/revision 978 | string?) 979 | 980 | (s/def :context/platform 981 | string?) 982 | 983 | (s/def :context/language 984 | ::language-tag) 985 | 986 | (s/def :context/statement 987 | ::statement-ref) 988 | 989 | (s/def :context/extensions 990 | ::extensions) 991 | 992 | ;; 2.0.x compat 993 | 994 | ;; contextAgents 995 | (s/def :contextAgent/objectType #{"contextAgent"}) 996 | (s/def :contextAgent/agent ::agent) 997 | (s/def :contextAgent/relevantTypes 998 | (s/every ::iri 999 | :into [] 1000 | :min-count 1)) 1001 | 1002 | (s/def ::context-agent 1003 | (conform-ns "contextAgent" 1004 | (s/and 1005 | (s/keys :req [:contextAgent/objectType 1006 | :contextAgent/agent] 1007 | :opt [:contextAgent/relevantTypes]) 1008 | (restrict-keys :contextAgent/objectType 1009 | :contextAgent/agent 1010 | :contextAgent/relevantTypes)))) 1011 | (s/def :context/contextAgents 1012 | (s/every ::context-agent 1013 | :into [])) 1014 | 1015 | ;; contextGroups 1016 | 1017 | (s/def :contextGroup/objectType #{"contextGroup"}) 1018 | (s/def :contextGroup/group ::group) 1019 | (s/def :contextGroup/relevantTypes 1020 | (s/every ::iri 1021 | :into [] 1022 | :min-count 1)) 1023 | 1024 | (s/def ::context-group 1025 | (conform-ns "contextGroup" 1026 | (s/and 1027 | (s/keys :req [:contextGroup/objectType 1028 | :contextGroup/group] 1029 | :opt [:contextGroup/relevantTypes]) 1030 | (restrict-keys :contextGroup/objectType 1031 | :contextGroup/group 1032 | :contextGroup/relevantTypes)))) 1033 | (s/def :context/contextGroups 1034 | (s/every ::context-group 1035 | :into [])) 1036 | 1037 | ;; multispec for dynamic params 1038 | (defmulti context-version (fn [_] *xapi-version*)) 1039 | 1040 | (defmethod context-version "1.0.3" [_] 1041 | (conform-ns 1042 | "context" 1043 | (s/and 1044 | (s/keys :opt [:context/registration 1045 | :context/instructor 1046 | :context/team 1047 | :context/contextActivities 1048 | :context/revision 1049 | :context/platform 1050 | :context/language 1051 | :context/statement 1052 | :context/extensions]) 1053 | (restrict-keys :context/registration 1054 | :context/instructor 1055 | :context/team 1056 | :context/contextActivities 1057 | :context/revision 1058 | :context/platform 1059 | :context/language 1060 | :context/statement 1061 | :context/extensions)))) 1062 | 1063 | (defmethod context-version "2.0.0" [_] 1064 | (conform-ns 1065 | "context" 1066 | (s/and 1067 | (s/keys :opt [:context/registration 1068 | :context/instructor 1069 | :context/team 1070 | :context/contextActivities 1071 | :context/revision 1072 | :context/platform 1073 | :context/language 1074 | :context/statement 1075 | :context/extensions 1076 | :context/contextAgents 1077 | :context/contextGroups]) 1078 | (restrict-keys :context/registration 1079 | :context/instructor 1080 | :context/team 1081 | :context/contextActivities 1082 | :context/revision 1083 | :context/platform 1084 | :context/language 1085 | :context/statement 1086 | :context/extensions 1087 | :context/contextAgents 1088 | :context/contextGroups)))) 1089 | 1090 | (s/def ::context 1091 | (s/multi-spec context-version (fn [gen-val _] 1092 | gen-val) )) 1093 | 1094 | ;; Attachments 1095 | 1096 | (s/def :attachment/usageType 1097 | ::iri) 1098 | 1099 | (s/def :attachment/display 1100 | ::language-map) 1101 | 1102 | (s/def :attachment/description 1103 | ::language-map) 1104 | 1105 | (s/def :attachment/contentType 1106 | string?) 1107 | 1108 | (s/def :attachment/length 1109 | int?) 1110 | 1111 | (s/def :attachment/sha2 1112 | ::sha2) 1113 | 1114 | (s/def :attachment/fileUrl 1115 | ::irl) 1116 | 1117 | ;; Note: The SHA2 hash may not correspond to any attachment with the given 1118 | ;; length and content type. This spec is okay for pure validation, but for 1119 | ;; generation a more sophisticated algorithm is recommended. 1120 | 1121 | (s/def ::file-attachment 1122 | (conform-ns "attachment" 1123 | (s/keys :req [:attachment/usageType 1124 | :attachment/display 1125 | :attachment/contentType 1126 | :attachment/length 1127 | :attachment/sha2] 1128 | :opt [:attachment/description 1129 | :attachment/fileUrl]))) 1130 | 1131 | (s/def ::url-attachment 1132 | (conform-ns "attachment" 1133 | (s/keys :req [:attachment/usageType 1134 | :attachment/display 1135 | :attachment/contentType 1136 | :attachment/length 1137 | :attachment/sha2 1138 | :attachment/fileUrl] 1139 | :opt [:attachment/description]))) 1140 | 1141 | (s/def ::attachment 1142 | (conform-ns "attachment" 1143 | (s/and 1144 | (s/keys :req [:attachment/usageType 1145 | :attachment/display 1146 | :attachment/contentType 1147 | :attachment/length 1148 | :attachment/sha2] 1149 | :opt [:attachment/description 1150 | :attachment/fileUrl]) 1151 | (restrict-keys 1152 | :attachment/usageType 1153 | :attachment/display 1154 | :attachment/contentType 1155 | :attachment/length 1156 | :attachment/sha2 1157 | :attachment/description 1158 | :attachment/fileUrl)))) 1159 | 1160 | (s/def ::attachments 1161 | (s/coll-of ::attachment :kind vector? :into [] :min-count 1)) 1162 | 1163 | ;; Sub-statement 1164 | 1165 | (s/def :sub-statement/actor 1166 | ::actor) 1167 | 1168 | (s/def :sub-statement/verb 1169 | ::verb) 1170 | 1171 | (defmulti sub-statement-object-type (fn [ss-o] 1172 | (case (get ss-o "objectType") 1173 | "Activity" :sub-statement-object/activity 1174 | nil :sub-statement-object/activity 1175 | "Agent" :sub-statement-object/agent 1176 | "Group" :sub-statement-object/group 1177 | "StatementRef" :sub-statement-object/statement-ref 1178 | ::s/invalid))) 1179 | 1180 | (defmethod sub-statement-object-type :sub-statement-object/activity [_] 1181 | ::activity) 1182 | 1183 | (defmethod sub-statement-object-type :sub-statement-object/agent [_] 1184 | ::agent) 1185 | 1186 | (defmethod sub-statement-object-type :sub-statement-object/group [_] 1187 | ::group) 1188 | 1189 | (defmethod sub-statement-object-type :sub-statement-object/statement-ref [_] 1190 | ::statement-ref) 1191 | 1192 | (s/def :sub-statement/object 1193 | (s/multi-spec sub-statement-object-type (fn [gen-val _] 1194 | gen-val))) 1195 | 1196 | (s/def :sub-statement/result 1197 | ::result) 1198 | 1199 | (s/def :sub-statement/context 1200 | ::context) 1201 | 1202 | (s/def :sub-statement/attachments 1203 | ::attachments) 1204 | 1205 | (s/def :sub-statement/timestamp 1206 | ::timestamp) 1207 | 1208 | (s/def :sub-statement/objectType 1209 | #{"SubStatement"}) 1210 | 1211 | (s/def ::sub-statement 1212 | (conform-ns "sub-statement" 1213 | (s/and (s/keys :req [:sub-statement/actor 1214 | :sub-statement/verb 1215 | :sub-statement/object 1216 | :sub-statement/objectType] 1217 | :opt [:sub-statement/result 1218 | :sub-statement/context 1219 | :sub-statement/attachments 1220 | :sub-statement/timestamp]) 1221 | (restrict-keys 1222 | :sub-statement/actor 1223 | :sub-statement/verb 1224 | :sub-statement/object 1225 | :sub-statement/objectType 1226 | :sub-statement/result 1227 | :sub-statement/context 1228 | :sub-statement/attachments 1229 | :sub-statement/timestamp) 1230 | (fn valid-context? [s] 1231 | (if (let [s-o (:sub-statement/object s)] 1232 | (or (:activity/objectType s-o) 1233 | (:activity/id s-o))) 1234 | true 1235 | (not (some-> s :sub-statement/context revision-or-platform?))))))) 1236 | 1237 | ;; Authority 1238 | 1239 | (s/def ::oauth-consumer 1240 | (conform-ns "agent" 1241 | (s/and 1242 | (s/keys :req [:agent/account] 1243 | :opt [:agent/objectType 1244 | :agent/name]) 1245 | (restrict-keys :agent/account 1246 | :agent/objectType 1247 | :agent/name)))) 1248 | 1249 | (s/def :tlo-group/member 1250 | (s/with-gen 1251 | (s/cat 1252 | :oauth-consumer 1253 | ::oauth-consumer 1254 | :agent 1255 | ::agent) 1256 | #(sgen/fmap vec (s/gen (s/cat 1257 | :oauth-consumer 1258 | ::oauth-consumer 1259 | :agent 1260 | ::agent))))) 1261 | 1262 | (s/def :tlo-group/objectType #{"Group"}) 1263 | (s/def :tlo-group/name ::string-not-empty) 1264 | (s/def :tlo-group/mbox :group/mbox) 1265 | (s/def :tlo-group/mbox_sha1sum :group/mbox_sha1sum) 1266 | (s/def :tlo-group/openid :group/openid) 1267 | (s/def :tlo-group/account :group/account) 1268 | 1269 | (s/def ::tlo-group 1270 | (conform-ns "tlo-group" 1271 | (s/and (s/keys :req [:tlo-group/objectType 1272 | :tlo-group/member] 1273 | :opt [ 1274 | :tlo-group/name]) 1275 | (restrict-keys :tlo-group/objectType 1276 | :tlo-group/member 1277 | :tlo-group/name)))) 1278 | 1279 | 1280 | ;; Statement! 1281 | 1282 | (s/def :statement/authority 1283 | (s/or :agent 1284 | ::agent 1285 | :oauth-consumer 1286 | ::oauth-consumer 1287 | :three-legged-oauth-group 1288 | ::tlo-group)) 1289 | 1290 | (defmulti statement-object-type (fn [ss-o] 1291 | (case (get ss-o "objectType") 1292 | "Activity" :statement-object/activity 1293 | nil :statement-object/activity 1294 | "Agent" :statement-object/agent 1295 | "Group" :statement-object/group 1296 | "StatementRef" :statement-object/statement-ref 1297 | "SubStatement" :statement-object/sub-statement 1298 | ::s/invalid))) 1299 | 1300 | (defmethod statement-object-type :statement-object/activity [_] 1301 | ::activity) 1302 | 1303 | (defmethod statement-object-type :statement-object/agent [_] 1304 | ::agent) 1305 | 1306 | (defmethod statement-object-type :statement-object/group [_] 1307 | ::group) 1308 | 1309 | (defmethod statement-object-type :statement-object/statement-ref [_] 1310 | ::statement-ref) 1311 | 1312 | (defmethod statement-object-type :statement-object/sub-statement [_] 1313 | ::sub-statement) 1314 | 1315 | 1316 | (s/def :statement/object 1317 | (s/multi-spec statement-object-type (fn [gen-val _] 1318 | gen-val))) 1319 | 1320 | (s/def :statement/id 1321 | ::uuid) 1322 | 1323 | (s/def :statement/actor 1324 | ::actor) 1325 | 1326 | (s/def :statement/verb 1327 | ::verb) 1328 | 1329 | (s/def :statement/result 1330 | ::result) 1331 | 1332 | (s/def :statement/context 1333 | ::context) 1334 | 1335 | (s/def :statement/timestamp 1336 | ::timestamp) 1337 | 1338 | (s/def :statement/stored 1339 | ::timestamp) 1340 | 1341 | (s/def :statement/version 1342 | ::version) 1343 | 1344 | (s/def :statement/attachments 1345 | ::attachments) 1346 | 1347 | (s/def :statement/objectType 1348 | #{"SubStatement"}) 1349 | 1350 | (s/def ::statement 1351 | (conform-ns "statement" 1352 | (s/and 1353 | (s/keys :req [:statement/actor 1354 | :statement/verb 1355 | :statement/object] 1356 | :opt [:statement/id 1357 | :statement/result 1358 | :statement/context 1359 | :statement/timestamp 1360 | :statement/stored 1361 | :statement/authority 1362 | :statement/attachments 1363 | :statement/version]) 1364 | (restrict-keys 1365 | :statement/actor 1366 | :statement/verb 1367 | :statement/object 1368 | :statement/id 1369 | :statement/result 1370 | :statement/context 1371 | :statement/timestamp 1372 | :statement/stored 1373 | :statement/authority 1374 | :statement/attachments 1375 | :statement/version) 1376 | (fn valid-context? [s] 1377 | (if (let [s-o (:statement/object s)] 1378 | (or (:activity/objectType s-o) 1379 | (:activity/id s-o))) 1380 | true 1381 | (not (some-> s :statement/context revision-or-platform?)))) 1382 | (fn valid-void? [s] 1383 | (if (some-> s :statement/verb :verb/id (= "http://adlnet.gov/expapi/verbs/voided")) 1384 | (some-> s :statement/object :statement-ref/objectType) 1385 | true))))) 1386 | 1387 | ;; A statement stored in the LRS should have some values always set 1388 | (s/def ::lrs-statement 1389 | (conform-ns "statement" 1390 | (s/and 1391 | (s/keys :req [:statement/id 1392 | :statement/actor 1393 | :statement/verb 1394 | :statement/object 1395 | :statement/timestamp 1396 | :statement/stored 1397 | :statement/authority 1398 | :statement/version] 1399 | :opt [:statement/result 1400 | :statement/context 1401 | :statement/attachments]) 1402 | (restrict-keys 1403 | :statement/actor 1404 | :statement/verb 1405 | :statement/object 1406 | :statement/id 1407 | :statement/result 1408 | :statement/context 1409 | :statement/timestamp 1410 | :statement/stored 1411 | :statement/authority 1412 | :statement/attachments 1413 | :statement/version) 1414 | (fn valid-context? [s] 1415 | (if (let [s-o (:statement/object s)] 1416 | (or (:activity/objectType s-o) 1417 | (:activity/id s-o))) 1418 | true 1419 | (not (some-> s :statement/context revision-or-platform?)))) 1420 | (fn valid-void? [s] 1421 | (if (some-> s :statement/verb :verb/id (= "http://adlnet.gov/expapi/verbs/voided")) 1422 | (some-> s :statement/object :statement-ref/objectType) 1423 | true))))) 1424 | 1425 | (defn unique-statement-ids? 1426 | "Spec predicate to ensure that the IDs of a list of statements are unique." 1427 | [statements] 1428 | (let [ids (keep #(get % "id") statements)] 1429 | (or 1430 | (empty? ids) 1431 | (reduce distinct? ids) 1432 | ::s/invalid))) 1433 | 1434 | (s/def ::statements 1435 | (s/and 1436 | (s/coll-of ::statement :into []) 1437 | unique-statement-ids?)) 1438 | 1439 | (s/def ::lrs-statements 1440 | (s/coll-of ::lrs-statement :into [])) 1441 | --------------------------------------------------------------------------------